From 7f277e271f8d01aff19865409f4f25f3a9ee957d Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 09:44:54 -0500 Subject: [PATCH 01/14] feat(bot-kit): authorize multicall envelopes in the signing policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional declarative Policy.multicall: calldata must decode as a non-empty multicall(bytes[]) whose inner selectors are allowed and whose inner calls' first address argument is registered for the outer target. Policy remains data-driven default-deny logic — no bot-supplied code runs inside the guard. Co-Authored-By: Claude Fable 5 --- packages/bot-kit/src/policy.ts | 70 +++++++++++++++++- packages/bot-kit/test/policy.test.ts | 106 ++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/packages/bot-kit/src/policy.ts b/packages/bot-kit/src/policy.ts index 3fb434c3..af012188 100644 --- a/packages/bot-kit/src/policy.ts +++ b/packages/bot-kit/src/policy.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from 'viem' -import { isAddressEqual } from 'viem' +import { decodeAbiParameters, isAddress, isAddressEqual } from 'viem' /** The only Executor entrypoint the signer authorizes: exec_606BaXt(bytes[]). */ export const EXECUTOR_SELECTOR = '0x00000001' @@ -11,6 +11,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 +35,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 +57,7 @@ export type PolicyCheck = | 'gas' | 'maxDataBytes' | 'selector' + | 'data' export type PolicyDecision = { ok: true } | { ok: false; check: PolicyCheck; message: string } @@ -65,7 +82,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 +113,52 @@ 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}`) } + if (policy.multicall) { + const reason = checkMulticall(policy.multicall, tx) + if (reason !== undefined) return deny('data', reason) + } return { ok: true } } + +// 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 + } +} + +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/test/policy.test.ts b/packages/bot-kit/test/policy.test.ts index cf56f6a6..bb4815e7 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,107 @@ 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, + executor: [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, + executor: 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('leaves policies without a multicall spec unchanged', () => { + expect(evaluatePolicy(POLICY, tx())).toEqual({ ok: true }) + }) +}) From 5df8b6b41a3580b7bdca3abae4e26b9b39179556 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 10:04:56 -0500 Subject: [PATCH 02/14] feat(vault-v2-reallocation): port vault v2 reallocation bot core RPC-only block-pinned vault data via blue-sdk fetchAccrualVaultV2 (+ per-id cap/allocation reads), both strategies as delta-emitting closures with three-level cap pools, multicall encoding, simulation, dep-injected tick, and the bot-kit-wired entrypoint. Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/package.json | 32 +++ bots/vault-v2-reallocation/scripts/build.ts | 29 ++ .../scripts/bundle-failed.error.ts | 11 + bots/vault-v2-reallocation/src/config.ts | 211 ++++++++++++++ bots/vault-v2-reallocation/src/encode.ts | 41 +++ bots/vault-v2-reallocation/src/index.ts | 220 ++++++++++++++ .../src/invalid-config.error.ts | 9 + .../src/invalid-vault.error.ts | 13 + bots/vault-v2-reallocation/src/math.ts | 144 ++++++++++ bots/vault-v2-reallocation/src/runner/tick.ts | 129 +++++++++ bots/vault-v2-reallocation/src/simulate.ts | 31 ++ .../src/strategies/apy-range.ts | 154 ++++++++++ .../src/strategies/equalize-utilizations.ts | 129 +++++++++ .../src/strategies/index.ts | 44 +++ .../src/strategies/strategy.ts | 22 ++ .../src/strategy-config.ts | 43 +++ bots/vault-v2-reallocation/src/tx-error.ts | 11 + bots/vault-v2-reallocation/src/vault-data.ts | 185 ++++++++++++ .../vault-v2-reallocation/test/config.test.ts | 78 +++++ .../vault-v2-reallocation/test/encode.test.ts | 57 ++++ bots/vault-v2-reallocation/test/math.test.ts | 138 +++++++++ .../test/runner/tick.test.ts | 144 ++++++++++ .../test/strategies/apy-range.test.ts | 269 ++++++++++++++++++ .../strategies/equalize-utilizations.test.ts | 191 +++++++++++++ .../test/strategies/helpers.ts | 87 ++++++ .../test/strategy-config.test.ts | 74 +++++ .../test/tx-error.test.ts | 23 ++ bots/vault-v2-reallocation/tsconfig.json | 8 + bots/vault-v2-reallocation/vitest.config.ts | 7 + knip.json | 1 + pnpm-lock.yaml | 46 +++ vitest.config.ts | 1 + 32 files changed, 2582 insertions(+) create mode 100644 bots/vault-v2-reallocation/package.json create mode 100644 bots/vault-v2-reallocation/scripts/build.ts create mode 100644 bots/vault-v2-reallocation/scripts/bundle-failed.error.ts create mode 100644 bots/vault-v2-reallocation/src/config.ts create mode 100644 bots/vault-v2-reallocation/src/encode.ts create mode 100644 bots/vault-v2-reallocation/src/index.ts create mode 100644 bots/vault-v2-reallocation/src/invalid-config.error.ts create mode 100644 bots/vault-v2-reallocation/src/invalid-vault.error.ts create mode 100644 bots/vault-v2-reallocation/src/math.ts create mode 100644 bots/vault-v2-reallocation/src/runner/tick.ts create mode 100644 bots/vault-v2-reallocation/src/simulate.ts create mode 100644 bots/vault-v2-reallocation/src/strategies/apy-range.ts create mode 100644 bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts create mode 100644 bots/vault-v2-reallocation/src/strategies/index.ts create mode 100644 bots/vault-v2-reallocation/src/strategies/strategy.ts create mode 100644 bots/vault-v2-reallocation/src/strategy-config.ts create mode 100644 bots/vault-v2-reallocation/src/tx-error.ts create mode 100644 bots/vault-v2-reallocation/src/vault-data.ts create mode 100644 bots/vault-v2-reallocation/test/config.test.ts create mode 100644 bots/vault-v2-reallocation/test/encode.test.ts create mode 100644 bots/vault-v2-reallocation/test/math.test.ts create mode 100644 bots/vault-v2-reallocation/test/runner/tick.test.ts create mode 100644 bots/vault-v2-reallocation/test/strategies/apy-range.test.ts create mode 100644 bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts create mode 100644 bots/vault-v2-reallocation/test/strategies/helpers.ts create mode 100644 bots/vault-v2-reallocation/test/strategy-config.test.ts create mode 100644 bots/vault-v2-reallocation/test/tx-error.test.ts create mode 100644 bots/vault-v2-reallocation/tsconfig.json create mode 100644 bots/vault-v2-reallocation/vitest.config.ts diff --git a/bots/vault-v2-reallocation/package.json b/bots/vault-v2-reallocation/package.json new file mode 100644 index 00000000..8325d17f --- /dev/null +++ b/bots/vault-v2-reallocation/package.json @@ -0,0 +1,32 @@ +{ + "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", + "start": "node --env-file-if-exists=.env dist/src/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@morpho-org/blue-sdk": "catalog:", + "@morpho-org/blue-sdk-viem": "catalog:", + "@morpho-org/morpho-ts": "catalog:", + "@repo/bot-kit": "workspace:*", + "@repo/utils": "workspace:*", + "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..c488a1a3 --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/build.ts @@ -0,0 +1,29 @@ +import { build as esbuild } from 'esbuild' +import { rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { BundleFailedError } from './bundle-failed.error' + +// Bundles the bot entrypoint to `dist/` 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 }) + +try { + await esbuild({ + entryPoints: [join(ROOT, 'src/index.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);" + } + }) +} 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/src/config.ts b/bots/vault-v2-reallocation/src/config.ts new file mode 100644 index 00000000..1e4b0403 --- /dev/null +++ b/bots/vault-v2-reallocation/src/config.ts @@ -0,0 +1,211 @@ +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. MetaMorpho vault 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. The old repo's +// EXECUTION_INTERVAL was documented in seconds but consumed as minutes; naming the unit here +// resolves that ambiguity by construction. +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 + +/** + * Deposit legs stop just short of each market's supply cap: the cap is scaled by this percentage + * 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_PERCENT = 99.99 + +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 liquidity in the vault's idle market. */ + 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 +} + +// 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) + 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 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`) + } + return 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 answers the MetaMorpho V1 surface) are performed in `index.ts` once a client exists. + */ +export const loadConfig = ( + env: Env = process.env, + deps: { chainMap?: Record } = {} +): Config => { + const chainMap = deps.chainMap ?? CHAIN_MAP + + 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 = chainMap[chainId] + if (!chain) { + const supported = Object.keys(chainMap).join(', ') || '(none configured)' + throw new InvalidConfigError( + `Unsupported CHAIN_ID ${chainId}; supported chain ids: ${supported}` + ) + } + + 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..ebcd508a --- /dev/null +++ b/bots/vault-v2-reallocation/src/index.ts @@ -0,0 +1,220 @@ +import type { Address } from 'viem' + +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 +} from '@repo/bot-kit' +import { ensureError, tryCatch } from '@repo/utils' +import { getAbiItem, toFunctionSelector } from 'viem' +import { getBlockNumber, readContract } from 'viem/actions' + +import { loadConfig } from './config' +import { encodeReallocation } from './encode' +import { InvalidVaultError } from './invalid-vault.error' +import { runTick } from './runner/tick' +import { simulateReallocate } from './simulate' +import { createStrategy } from './strategies' +import { revertReason } from './tx-error' +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() + // Global wide-log context stamped onto every line: the bot identity + chain, plus whichever + // RAILWAY_* identity vars this deployment exposes. + const logger = createLogger(config.logLevel, { + context: { bot: 'vault-v2-reallocation', chainId: config.chainId, ...railwayContext() } + }) + + const client = createDeploylessClient(config) + + // Startup vault validation, which also gathers the adapter set the signing policy binds to: + // fetchVaultV2Data proves each whitelisted address is a factory-made VaultV2 with exactly one + // MorphoMarketV1 adapter (typed InvalidVaultError otherwise — fatal, since the policy authorizes + // the address as a tx target). isAdapter is a cheap cross-check that the vault recognizes it. + const startupBlock = await getBlockNumber(client) + const adapterByVault: Record = {} + for (const vault of config.vaultWhitelist) { + await assertContractDeployed(client, vault, 'VAULT_WHITELIST entry') + const vaultData = await fetchVaultV2Data(client, vault, { + chainId: config.chainId, + blockNumber: startupBlock + }) + const recognized = await readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'isAdapter', + args: [vaultData.adapterAddress] + }) + if (!recognized) { + throw new InvalidVaultError( + `vault ${vault} does not recognize adapter ${vaultData.adapterAddress}` + ) + } + adapterByVault[vault] = [vaultData.adapterAddress] + } + + // Signed-send path: a plain wallet client + local nonce cursor (separate from the read client). + // 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, + executor: 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 + }) + const eoa = signer.account.address + + logger.info('startup', { + chainId: config.chainId, + reallocator: eoa, + vaults: config.vaultWhitelist, + adapters: adapterByVault, + strategy: config.strategy, + intervalMs: config.reallocationIntervalMs, + dryRun: config.dryRun + }) + + // The allocator role is probed non-fatally: a pending grant must not crash-loop the bot; the tick + // re-checks and resumes on its own. + const isAllocator = (vault: Address) => + readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'isAllocator', + args: [eoa] + }) + for (const vault of config.vaultWhitelist) { + const role = await tryCatch(isAllocator(vault)) + if (role.error || !role.data) { + logger.warn('allocator.missing_role', { + vault, + detail: 'grant the allocator role to the EOA' + }) + } + } + + // Transaction-queue state is in-memory only — chain truth wins on restart. A redeploy re-derives + // the nonce cursor from `getTransactionCount('pending')`, and any tx that was in flight settles + // on-chain regardless of the bot; settlement audit ships via the structured `tx.*` log events. + 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. + let lastRunMs = 0 + const tick = async (chainHead: bigint) => { + if (Date.now() - lastRunMs < config.reallocationIntervalMs) return + lastRunMs = Date.now() + await runTick({ + vaults: config.vaultWhitelist, + chainHead, + isAllocator, + fetchVault: (vault, blockNumber) => + fetchVaultV2Data(client, vault, { chainId: config.chainId, blockNumber }), + strategy, + encodeReallocation: (vaultData, reallocation) => + encodeReallocation(vaultData.adapterAddress, reallocation), + simulate: (vault, data) => simulateReallocate(client, { vault, eoa, data }), + submit: async ({ vault, data, blockNumber }) => { + const fees = initialFees(await signer.getBaseFee(), config.maxFeeWei) + await 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. + console.error( + JSON.stringify({ level: 'error', event: 'startup.error', error: ensureError(error).message }) + ) + process.exitCode = 1 +}) 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..885e7981 --- /dev/null +++ b/bots/vault-v2-reallocation/src/math.ts @@ -0,0 +1,144 @@ +import type { Address } from 'viem' + +import { AdaptiveCurveIrmLib, MathLib } from '@morpho-org/blue-sdk' +import { getAddress, parseUnits } from 'viem' + +import type { CapState, MarketState, VaultV2Data, VaultV2MarketData } from './vault-data' + +const SECONDS_PER_YEAR = 60n * 60n * 24n * 365n + +/** Converts a human percentage (e.g. `4.25`) to its WAD-scaled fraction. */ +export const percentToWad = (percent: number): bigint => parseUnits(percent.toString(), 16) + +/** 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) + +const getWithdrawalToUtilization = (state: MarketState, targetUtilization: bigint): bigint => + MathLib.wMulDown( + state.totalSupplyAssets, + MathLib.WAD - MathLib.wDivDown(getUtilization(state), targetUtilization) + ) + +const getDepositToUtilization = (state: MarketState, targetUtilization: bigint): bigint => + MathLib.wMulDown( + state.totalSupplyAssets, + MathLib.wDivDown(getUtilization(state), targetUtilization) - 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: min of the buffered absolute cap and the buffered + * relative cap (fraction of `totalAssets`) minus the id's on-chain `allocation` — the value the + * contract enforces both caps against (accrued position assets drift above it as interest accrues). + */ +export const getCapHeadroom = ( + cap: CapState, + totalAssets: bigint, + capBufferPercent: number +): bigint => { + const buffer = percentToWad(capBufferPercent) + const bufferedAbsolute = MathLib.wMulDown(cap.absolute, buffer) + const absoluteHeadroom = + bufferedAbsolute > cap.allocation ? bufferedAbsolute - cap.allocation : 0n + const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), buffer) + const relativeHeadroom = + bufferedRelative > cap.allocation ? bufferedRelative - cap.allocation : 0n + return MathLib.min(absoluteHeadroom, relativeHeadroom) +} + +/** + * Assets allocatable into a market before its utilization would fall below `targetUtilization`, + * bounded by the market cap id's remaining headroom ({@link getCapHeadroom}). 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, + capBufferPercent: number +): bigint => + MathLib.min( + getDepositToUtilization(marketData.state, targetUtilization), + getCapHeadroom(marketData.cap, totalAssets, capBufferPercent) + ) + +/** + * Shared deposit ceilings above the per-market caps: the adapter-level ("this") cap id and one pool + * per collateral cap id, all enforced on-chain against `allocation(id)`. Strategies draw legs + * through {@link takeFromPools} so a plan can never exceed an aggregate cap. + */ +type DepositPools = { + adapter: bigint + byCollateral: Map +} + +export const createDepositPools = ( + vaultData: VaultV2Data, + capBufferPercent: number +): DepositPools => ({ + adapter: getCapHeadroom(vaultData.adapterCap, vaultData.totalAssets, capBufferPercent), + byCollateral: new Map( + Object.entries(vaultData.collateralCaps).map(([token, cap]) => [ + getAddress(token), + getCapHeadroom(cap, vaultData.totalAssets, capBufferPercent) + ]) + ) +}) + +/** 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 collateralPool = pools.byCollateral.get(key) ?? 0n + const taken = MathLib.min(amount, MathLib.min(pools.adapter, collateralPool)) + pools.adapter -= taken + pools.byCollateral.set(key, collateralPool - 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..afef34f1 --- /dev/null +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -0,0 +1,129 @@ +import type { Logger } from '@repo/bot-kit' +import type { Address, Hex } from 'viem' + +import { tryCatch } from '@repo/utils' + +import type { SimulateResult } from '../simulate' +import type { Reallocation, Strategy } from '../strategies' +import type { VaultV2Data } from '../vault-data' + +export type TickDeps = { + vaults: Address[] + chainHead: bigint + /** Live allocator-role check; a vault without the role is skipped (and resumes once granted). */ + isAllocator: (vault: Address) => Promise + fetchVault: (vault: Address, blockNumber: bigint) => Promise + strategy: Strategy + encodeReallocation: (vaultData: VaultV2Data, reallocation: Reallocation) => Hex + simulate: (vault: Address, data: Hex) => Promise + 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 TickCounters = { + vaults: number + skipped_inflight: number + missing_role: number + reallocations_found: number + sim_reverts: number + dry_runs: number + submitted: number + errors: number +} + +const summarize = (reallocation: Reallocation) => [ + ...reallocation.deallocations.map(leg => ({ + action: 'deallocate', + collateralToken: leg.marketParams.collateralToken, + lltv: leg.marketParams.lltv, + assets: leg.assets + })), + ...reallocation.allocations.map(leg => ({ + action: 'allocate', + collateralToken: leg.marketParams.collateralToken, + lltv: leg.marketParams.lltv, + assets: leg.assets + })) +] + +const processVault = async ( + deps: TickDeps, + vault: Address, + counters: TickCounters +): Promise => { + if (!(await deps.isAllocator(vault))) { + counters.missing_role++ + deps.logger.warn('allocator.missing_role', { vault }) + return + } + + const vaultData = await deps.fetchVault(vault, deps.chainHead) + const reallocation = deps.strategy(vaultData) + if (!reallocation) return + counters.reallocations_found++ + + const legs = reallocation.deallocations.length + reallocation.allocations.length + const summary = summarize(reallocation) + deps.logger.info('reallocation.found', { vault, legs, allocations: summary }) + + const data = deps.encodeReallocation(vaultData, reallocation) + const sim = await deps.simulate(vault, data) + if (sim.status === 'revert') { + counters.sim_reverts++ + deps.logger.warn('reallocation.sim_revert', { vault, reason: sim.reason }) + return + } + + if (deps.dryRun) { + counters.dry_runs++ + deps.logger.info('reallocation.dry_run', { vault, legs, allocations: summary }) + return + } + + await deps.submit({ vault, data, blockNumber: deps.chainHead }) + counters.submitted++ +} + +/** + * One reallocation pass: for each whitelisted vault — skip if a tx is in flight, skip (loudly) if + * the allocator role is missing, then fetch a block-pinned snapshot, 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; one wide `tick.end` counters line closes the pass. + */ +export const runTick = async (deps: TickDeps): Promise => { + const started = Date.now() + const counters: TickCounters = { + vaults: deps.vaults.length, + skipped_inflight: 0, + missing_role: 0, + reallocations_found: 0, + sim_reverts: 0, + dry_runs: 0, + submitted: 0, + errors: 0 + } + + for (const vault of deps.vaults) { + if (deps.inflightLabels().has(vault)) { + counters.skipped_inflight++ + deps.logger.debug('vault.inflight', { vault }) + continue + } + const { error } = await tryCatch(processVault(deps, vault, counters)) + if (error) { + counters.errors++ + deps.logger.error('vault.error', { vault, reason: deps.revertReason(error) }) + } + } + + deps.logger.info('tick.end', { + blockNumber: deps.chainHead, + ...counters, + duration_ms: Date.now() - started + }) +} diff --git a/bots/vault-v2-reallocation/src/simulate.ts b/bots/vault-v2-reallocation/src/simulate.ts new file mode 100644 index 00000000..23b8df3e --- /dev/null +++ b/bots/vault-v2-reallocation/src/simulate.ts @@ -0,0 +1,31 @@ +import type { Address, Client, Hex } from 'viem' + +import { tryCatch } from '@repo/utils' +import { BaseError } from 'viem' +import { call } from 'viem/actions' + +export type SimulateResult = { + /** `ok` — the reallocate succeeds from this EOA, safe to broadcast. `revert` — do not send. */ + status: 'ok' | 'revert' + reason?: string +} + +/** + * Simulates the real reallocation — `vault.multicall([deallocate…, allocate…])` from the allocator + * EOA, byte-for-byte what gets broadcast. Any revert (role revoked, cap exceeded, insufficient + * idle, market no longer enabled) means do not broadcast; the tick gates on `ok` only. No signer; + * never sends. + */ +export const simulateReallocate = async ( + client: Client, + params: { vault: Address; eoa: Address; data: Hex } +): Promise => { + const { error } = await tryCatch( + call(client, { account: params.eoa, to: params.vault, data: params.data, value: 0n }) + ) + if (!error) return { status: 'ok' } + return { + status: 'revert', + reason: error instanceof BaseError ? error.shortMessage : error.message + } +} 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..296da756 --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -0,0 +1,154 @@ +import type { Address, Hex } from 'viem' + +import { MathLib } from '@morpho-org/blue-sdk' + +import type { Reallocation, ReallocationAction, Strategy } from './strategy' + +import { + apyToRate, + createDepositPools, + getDepositableAmount, + getUtilization, + getWithdrawableAmount, + rateToApy, + rateToUtilization, + takeFromPools, + utilizationToRate +} from '../math' + +export type ApyRangeConfig = { + /** Whether excess deallocations may be parked in the vault's idle balance. */ + allowIdleReallocation: boolean + capBufferPercent: number + /** 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 { min } = MathLib + +/** + * Keeps each market's borrow APY inside its configured range: converts the APY bounds to + * utilization bounds via the AdaptiveCurveIRM inverse, deallocates from markets below range, + * allocates into markets above range. Allocations may exceed deallocations by up to the vault's + * idle balance; excess deallocations park in idle unless `allowIdleReallocation` is off, in which + * case they are clamped to the allocation total. Assumes every market uses the AdaptiveCurveIRM. + * Allocations respect the market, adapter-level, and collateral-level caps. + */ +export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { + return vaultData => { + const vault = vaultData.vaultAddress + const marketsData = vaultData.marketsData + + let totalAmountToDeallocate = 0n + let totalAmountToAllocate = 0n + + let didExceedMinApyDelta = false // true if *at least one* market moves enough + + // Both passes iterate markets in the same order with the same clamps, so the totals gathered + // here (pool-clamped) equal what the leg pass can actually emit. + const sizingPools = createDepositPools(vaultData, config.capBufferPercent) + for (const marketData of marketsData) { + const apyRange = config.apyRange(vault, marketData.id) + const upperUtilizationBound = rateToUtilization( + apyToRate(apyRange.max), + marketData.rateAtTarget + ) + const lowerUtilizationBound = rateToUtilization( + apyToRate(apyRange.min), + marketData.rateAtTarget + ) + const utilization = getUtilization(marketData.state) + + if (utilization > upperUtilizationBound) { + totalAmountToAllocate += takeFromPools( + sizingPools, + marketData.params.collateralToken, + getDepositableAmount( + marketData, + vaultData.totalAssets, + upperUtilizationBound, + config.capBufferPercent + ) + ) + const apyDelta = + rateToApy(utilizationToRate(upperUtilizationBound, marketData.rateAtTarget)) - + rateToApy(utilizationToRate(utilization, marketData.rateAtTarget)) + didExceedMinApyDelta ||= + Math.abs(Number(apyDelta / 1_000_000_000n) / 1e5) > + config.minApyDeltaBips(vault, marketData.id) + } else if (utilization < lowerUtilizationBound) { + totalAmountToDeallocate += getWithdrawableAmount(marketData, lowerUtilizationBound) + const apyDelta = + rateToApy(utilizationToRate(lowerUtilizationBound, marketData.rateAtTarget)) - + rateToApy(utilizationToRate(utilization, marketData.rateAtTarget)) + didExceedMinApyDelta ||= + Math.abs(Number(apyDelta / 1_000_000_000n) / 1e5) > + config.minApyDeltaBips(vault, marketData.id) + } + } + + if (totalAmountToDeallocate > totalAmountToAllocate && !config.allowIdleReallocation) { + totalAmountToDeallocate = totalAmountToAllocate + } else if (totalAmountToAllocate > totalAmountToDeallocate) { + totalAmountToAllocate = + totalAmountToDeallocate + + min(totalAmountToAllocate - totalAmountToDeallocate, vaultData.idleAssets) + } + + if (min(totalAmountToDeallocate, totalAmountToAllocate) === 0n || !didExceedMinApyDelta) { + return undefined + } + + let remainingAmountToDeallocate = totalAmountToDeallocate + let remainingAmountToAllocate = totalAmountToAllocate + + const allocations: ReallocationAction[] = [] + const deallocations: ReallocationAction[] = [] + + const legPools = createDepositPools(vaultData, config.capBufferPercent) + for (const marketData of marketsData) { + const apyRange = config.apyRange(vault, marketData.id) + const upperUtilizationBound = rateToUtilization( + apyToRate(apyRange.max), + marketData.rateAtTarget + ) + const lowerUtilizationBound = rateToUtilization( + apyToRate(apyRange.min), + marketData.rateAtTarget + ) + const utilization = getUtilization(marketData.state) + + if (utilization > upperUtilizationBound) { + const desired = min( + getDepositableAmount( + marketData, + vaultData.totalAssets, + upperUtilizationBound, + config.capBufferPercent + ), + remainingAmountToAllocate + ) + const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) + remainingAmountToAllocate -= toAllocate + if (toAllocate > 0n) { + allocations.push({ marketParams: marketData.params, assets: toAllocate }) + } + } else if (utilization < lowerUtilizationBound) { + const toDeallocate = min( + getWithdrawableAmount(marketData, lowerUtilizationBound), + remainingAmountToDeallocate + ) + remainingAmountToDeallocate -= toDeallocate + if (toDeallocate > 0n) { + deallocations.push({ marketParams: marketData.params, assets: toDeallocate }) + } + } + + if (remainingAmountToDeallocate === 0n && remainingAmountToAllocate === 0n) break + } + + return { allocations, deallocations } satisfies Reallocation + } +} 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..2a18add9 --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -0,0 +1,129 @@ +import type { Address } from 'viem' + +import { MathLib } from '@morpho-org/blue-sdk' +import { zeroAddress } from 'viem' + +import type { Reallocation, ReallocationAction, Strategy } from './strategy' + +import { + createDepositPools, + getDepositableAmount, + getUtilization, + getWithdrawableAmount, + takeFromPools +} from '../math' + +type EqualizeUtilizationsConfig = { + capBufferPercent: number + /** Firing threshold: at least one market's utilization must deviate from target by this (bips). */ + minUtilizationDeltaBips: (vault: Address) => number +} + +const { min, wDivDown } = MathLib + +/** + * 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. Legs need not + * balance — the difference flows through the vault's idle balance. Fires only when at least one + * market's deviation exceeds the vault's min-delta threshold. Allocations respect the market, + * adapter-level, and collateral-level caps. + */ +export const createEqualizeUtilizationsStrategy = ( + config: EqualizeUtilizationsConfig +): Strategy => { + return vaultData => { + const marketsData = vaultData.marketsData.filter( + marketData => marketData.params.collateralToken !== zeroAddress + ) + + 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 targetUtilization = wDivDown(totalBorrow, totalSupply) + + let totalAmountToDeallocate = 0n + let totalAmountToAllocate = 0n + + let didExceedMinUtilizationDelta = false // true if *at least one* market moves enough + const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) + + // Both passes iterate markets in the same order with the same clamps, so the totals gathered + // here (pool-clamped) equal what the leg pass can actually emit. + const sizingPools = createDepositPools(vaultData, config.capBufferPercent) + for (const marketData of marketsData) { + const utilization = getUtilization(marketData.state) + if (utilization > targetUtilization) { + totalAmountToAllocate += takeFromPools( + sizingPools, + marketData.params.collateralToken, + getDepositableAmount( + marketData, + vaultData.totalAssets, + targetUtilization, + config.capBufferPercent + ) + ) + } else { + totalAmountToDeallocate += getWithdrawableAmount(marketData, targetUtilization) + } + + didExceedMinUtilizationDelta ||= + Math.abs(Number((utilization - targetUtilization) / 1_000_000_000n) / 1e5) > + minUtilizationDeltaBips + } + + if ( + min(totalAmountToDeallocate, totalAmountToAllocate) === 0n || + !didExceedMinUtilizationDelta + ) { + return undefined + } + + let remainingAmountToDeallocate = totalAmountToDeallocate + let remainingAmountToAllocate = totalAmountToAllocate + + const allocations: ReallocationAction[] = [] + const deallocations: ReallocationAction[] = [] + + const legPools = createDepositPools(vaultData, config.capBufferPercent) + for (const marketData of marketsData) { + const utilization = getUtilization(marketData.state) + + if (utilization > targetUtilization) { + const desired = min( + getDepositableAmount( + marketData, + vaultData.totalAssets, + targetUtilization, + config.capBufferPercent + ), + remainingAmountToAllocate + ) + const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) + remainingAmountToAllocate -= toAllocate + if (toAllocate > 0n) { + allocations.push({ marketParams: marketData.params, assets: toAllocate }) + } + } else { + const toDeallocate = min( + getWithdrawableAmount(marketData, targetUtilization), + remainingAmountToDeallocate + ) + remainingAmountToDeallocate -= toDeallocate + if (toDeallocate > 0n) { + deallocations.push({ marketParams: marketData.params, assets: toDeallocate }) + } + } + + if (remainingAmountToDeallocate === 0n && remainingAmountToAllocate === 0n) break + } + + return { allocations, deallocations } satisfies Reallocation + } +} 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..3c674d3a --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/index.ts @@ -0,0 +1,44 @@ +import { assertNever } from '@repo/utils' + +import type { Config } from '../config' +import type { Strategy } from './strategy' + +import { CAP_BUFFER_PERCENT } from '../config' +import { percentToWad } from '../math' +import { + resolveApyRange, + resolveMinApyDeltaBips, + resolveMinUtilizationDeltaBips +} from '../strategy-config' +import { createApyRangeStrategy } from './apy-range' +import { createEqualizeUtilizationsStrategy } from './equalize-utilizations' + +export type { Reallocation, 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, + capBufferPercent: CAP_BUFFER_PERCENT, + apyRange: (vault, marketId) => { + const range = resolveApyRange(config.chainId, vault, marketId) + return { min: percentToWad(range.min), max: percentToWad(range.max) } + }, + minApyDeltaBips: (vault, marketId) => + resolveMinApyDeltaBips(config.chainId, vault, marketId, config.minApyDeltaBips) + }) + case 'equalize-utilizations': + return createEqualizeUtilizationsStrategy({ + capBufferPercent: CAP_BUFFER_PERCENT, + minUtilizationDeltaBips: vault => + resolveMinUtilizationDeltaBips(config.chainId, vault, config.minUtilizationDeltaBips) + }) + default: + return assertNever(config.strategy) + } +} 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..86b28bdc --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/strategy.ts @@ -0,0 +1,22 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' + +import type { VaultV2Data } from '../vault-data' + +/** One delta leg: assets to allocate to (or deallocate from) the market with these params. */ +export type ReallocationAction = { + 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..5653eabe --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategy-config.ts @@ -0,0 +1,43 @@ +import type { Address, Hex } from 'viem' + +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> = {} + +/** 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-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts new file mode 100644 index 00000000..70cef027 --- /dev/null +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -0,0 +1,185 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { Address, Client, Hex } from 'viem' + +import { + AccrualVaultV2MorphoMarketV1Adapter, + VaultV2MorphoMarketV1Adapter +} from '@morpho-org/blue-sdk' +import { fetchAccrualVaultV2, vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { getAddress } from 'viem' +import { readContract } from 'viem/actions' + +import { InvalidVaultError } from './invalid-vault.error' + +export type MarketState = { + totalSupplyAssets: bigint + totalSupplyShares: bigint + totalBorrowAssets: bigint + totalBorrowShares: 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 +} + +export type VaultV2Data = { + vaultAddress: Address + adapterAddress: Address + /** The vault's total assets, interest accrued to now. */ + 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[] +} + +const readCap = async ( + client: Client, + vault: Address, + id: Hex, + blockNumber: bigint +): Promise => { + const [absolute, relative, allocation] = await Promise.all([ + readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'absoluteCap', + args: [id], + blockNumber + }), + readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'relativeCap', + args: [id], + blockNumber + }), + readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'allocation', + args: [id], + blockNumber + }) + ]) + return { absolute, relative, allocation } +} + +/** + * Reads one VaultV2's full reallocation input over RPC, pinned to `blockNumber` for a coherent + * snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` (which also proves the + * address is a factory-made VaultV2), plus the per-id cap/allocation reads the SDK fetcher does not + * cover for a regular adapter (market ids, the adapter id, and each distinct collateral id). + * Throws {@link InvalidVaultError} unless the vault has exactly one adapter and it is a + * MorphoMarketV1 adapter. Throws on any failed read — the tick catches per vault. + */ +export const fetchVaultV2Data = async ( + client: Client, + vault: Address, + { chainId, blockNumber }: { chainId: number; blockNumber: bigint } +): Promise => { + const vaultV2 = await fetchAccrualVaultV2(vault, client, { chainId, blockNumber }) + + const marketAdapters = vaultV2.accrualAdapters.filter( + (adapter): adapter is AccrualVaultV2MorphoMarketV1Adapter => + adapter instanceof AccrualVaultV2MorphoMarketV1Adapter + ) + if (vaultV2.adapters.length !== 1 || marketAdapters.length !== 1) { + throw new InvalidVaultError( + `vault ${vault} must have exactly one MorphoMarketV1 adapter; found ` + + `${vaultV2.adapters.length} adapter(s) of which ${marketAdapters.length} are MorphoMarketV1` + ) + } + const adapter = marketAdapters[0]! + const adapterAddress = getAddress(adapter.address) + + const now = BigInt(Math.floor(Date.now() / 1000)) + + const positionByMarketId = new Map( + adapter.positions.map(position => [position.marketId, position]) + ) + const collateralTokens = [ + ...new Set(adapter.marketParamsList.map(params => getAddress(params.collateralToken))) + ] + + const [adapterCap, collateralCapList, marketsData] = await Promise.all([ + readCap(client, vault, VaultV2MorphoMarketV1Adapter.adapterId(adapterAddress), blockNumber), + Promise.all( + collateralTokens.map(async token => ({ + token, + cap: await readCap( + client, + vault, + VaultV2MorphoMarketV1Adapter.collateralId(token), + blockNumber + ) + })) + ), + Promise.all( + adapter.marketParamsList.map(async (params): Promise => { + const position = positionByMarketId.get(params.id) + if (position === undefined) { + throw new InvalidVaultError( + `vault ${vault} adapter position missing for market ${params.id}` + ) + } + const capId = VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, params) + const cap = await readCap(client, vault, capId, blockNumber) + const accrued = position.accrueInterest(now) + return { + id: params.id, + capId, + params: { + loanToken: params.loanToken, + collateralToken: params.collateralToken, + oracle: params.oracle, + irm: params.irm, + lltv: params.lltv + }, + state: { + totalSupplyAssets: accrued.market.totalSupplyAssets, + totalSupplyShares: accrued.market.totalSupplyShares, + totalBorrowAssets: accrued.market.totalBorrowAssets, + totalBorrowShares: accrued.market.totalBorrowShares + }, + cap, + vaultAssets: accrued.supplyAssets, + rateAtTarget: accrued.market.rateAtTarget ?? 0n + } + }) + ) + ]) + + return { + vaultAddress: vault, + adapterAddress, + totalAssets: vaultV2.accrueInterest(now).vault._totalAssets, + idleAssets: vaultV2.assetBalance, + adapterCap, + collateralCaps: Object.fromEntries(collateralCapList.map(({ token, cap }) => [token, cap])), + marketsData + } +} 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..5ba24bad --- /dev/null +++ b/bots/vault-v2-reallocation/test/config.test.ts @@ -0,0 +1,78 @@ +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') + 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.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' }], + ['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..84daf57b --- /dev/null +++ b/bots/vault-v2-reallocation/test/encode.test.ts @@ -0,0 +1,57 @@ +import { marketParamsAbi } from '@morpho-org/blue-sdk' +import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { decodeAbiParameters, decodeFunctionData, getAddress, parseUnits } from 'viem' +import { beforeEach, describe, expect, it } from 'vitest' + +import { encodeReallocation } from '../src/encode' +import { ADAPTER, makeMarketParams, resetMarketCounter } from './strategies/helpers' + +describe('encodeReallocation', () => { + beforeEach(() => { + resetMarketCounter() + }) + + it('encodes deallocate legs strictly before allocate legs with exact args', () => { + const hotParams = makeMarketParams() + const coldParams = makeMarketParams() + const data = encodeReallocation(ADAPTER, { + allocations: [{ marketParams: hotParams, assets: parseUnits('123', 6) }], + deallocations: [{ 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: [{ 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/math.test.ts b/bots/vault-v2-reallocation/test/math.test.ts new file mode 100644 index 00000000..2298c825 --- /dev/null +++ b/bots/vault-v2-reallocation/test/math.test.ts @@ -0,0 +1,138 @@ +import { getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + createDepositPools, + getCapHeadroom, + getDepositableAmount, + getUtilization, + getWithdrawableAmount, + percentToWad, + takeFromPools +} from '../src/math' +import { makeMarket, makeVaultData, RATE_AT_TARGET } from './strategies/helpers' + +const WAD = 10n ** 18n + +describe('getCapHeadroom', () => { + const totalAssets = parseUnits('100000', 6) + + it('measures headroom against the on-chain allocation, not accrued assets', () => { + // allocation lags accrued position assets; headroom must use allocation. + const cap = { + absolute: parseUnits('11000', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + const headroom = getCapHeadroom(cap, totalAssets, 100) + expect(headroom).toBe(parseUnits('1000', 6)) + }) + + it('applies the buffer to both cap legs and floors at zero', () => { + const cap = { + absolute: parseUnits('10000', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + // Buffered absolute (99.99%) is below the allocation → zero headroom. + expect(getCapHeadroom(cap, totalAssets, 99.99)).toBe(0n) + }) + + it('binds on the 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, totalAssets, 100)).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, + 100 + ) + expect(depositable).toBe(parseUnits('100', 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('returns 0 utilization for an empty market instead of dividing by zero', () => { + expect( + getUtilization({ + totalSupplyAssets: 0n, + totalSupplyShares: 0n, + totalBorrowAssets: 0n, + totalBorrowShares: 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, 100) + // 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).toBe(parseUnits('100', 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]), 100) + expect(takeFromPools(pools, getAddress(`0x${'77'.repeat(20)}`), parseUnits('1', 6))).toBe(0n) + }) +}) + +describe('percentToWad', () => { + it('scales percentages to WAD fractions', () => { + expect(percentToWad(100)).toBe(WAD) + expect(percentToWad(99.99)).toBe(parseUnits('0.9999', 18)) + }) +}) 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..edda59fc --- /dev/null +++ b/bots/vault-v2-reallocation/test/runner/tick.test.ts @@ -0,0 +1,144 @@ +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 '../strategies/helpers' + +function 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: [{ marketParams: makeMarketParams(), assets: parseUnits('1', 6) }], + deallocations: [{ marketParams: makeMarketParams(), assets: parseUnits('1', 6) }] +}) + +const makeDeps = (overrides: Partial = {}) => { + const { logger, events } = spyLogger() + const deps: TickDeps = { + vaults: [VAULT_A], + chainHead: 100n, + isAllocator: vi.fn(async () => true), + 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 () => undefined), + 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('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.isAllocator).not.toHaveBeenCalled() + expect(deps.fetchVault).not.toHaveBeenCalled() + expect(tickEnd(events)).toMatchObject({ skipped_inflight: 1 }) + }) + + it('skips fetch/strategy/simulate while the allocator role is missing', async () => { + const { deps, events } = makeDeps({ isAllocator: vi.fn(async () => false) }) + await runTick(deps) + expect(deps.fetchVault).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/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts new file mode 100644 index 00000000..2b4fd186 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -0,0 +1,269 @@ +import type { Hex } from 'viem' + +import { parseUnits } from 'viem' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { ApyRangeConfig } from '../../src/strategies/apy-range' + +import { apyToRate, percentToWad, rateToUtilization } from '../../src/math' +import { createApyRangeStrategy } from '../../src/strategies/apy-range' +import { makeMarket, makeVaultData, RATE_AT_TARGET, resetMarketCounter } 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, + capBufferPercent: 99.99, + apyRange: (_vault, marketId) => { + const range = overrides.marketApyRanges?.[marketId] ?? defaultApyRange + return { min: percentToWad(range.min), max: percentToWad(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(percentToWad(apyPercent)), rateAtTarget) + +describe('createApyRangeStrategy', () => { + beforeEach(() => { + resetMarketCounter() + }) + + 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: with no deallocation source the whole leg is idle-funded... + // but min(dealloc, alloc) === 0 with dealloc 0 — matching the old bot, 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('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) + expect(totalAllocated).toBeLessThanOrEqual(parseUnits('50', 6)) + 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..ac12a707 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -0,0 +1,191 @@ +import { getAddress, parseUnits } from 'viem' +import { beforeEach, describe, expect, it } from 'vitest' + +import { createEqualizeUtilizationsStrategy } from '../../src/strategies/equalize-utilizations' +import { makeMarket, makeVaultData, RATE_AT_TARGET, resetMarketCounter, VAULT } from './helpers' + +const makeStrategy = (minUtilizationDeltaBips: (vault: `0x${string}`) => number = () => 0) => + createEqualizeUtilizationsStrategy({ capBufferPercent: 99.99, minUtilizationDeltaBips }) + +const WAD = 10n ** 18n + +describe('createEqualizeUtilizationsStrategy', () => { + beforeEach(() => { + resetMarketCounter() + }) + + 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. The observable + // effect: adding a cold market makes idle tilt the split. + expect(strategy(makeVaultData([market]))).toBeUndefined() + expect( + strategy(makeVaultData([market], { idleAssets: parseUnits('1000000', 6) })) + ).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 against allocation(id)', () => { + const strategy = makeStrategy() + const vaultAssets = parseUnits('10000', 6) + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets, + // The enforced allocation already sits 100 under the absolute cap even though position + // assets accrued past it — headroom must come from allocation, not vaultAssets. + cap: { absolute: vaultAssets + parseUnits('100', 6), relative: WAD, allocation: vaultAssets }, + 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('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) + expect(totalAllocated).toBeLessThanOrEqual(parseUnits('50', 6)) + 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 + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + 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/helpers.ts b/bots/vault-v2-reallocation/test/strategies/helpers.ts new file mode 100644 index 00000000..ac8f2ba6 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/helpers.ts @@ -0,0 +1,87 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { Hex } from 'viem' + +import { MathLib } 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) / (365n * 24n * 60n * 60n) + +const UNLIMITED_CAP: CapState = { + absolute: maxUint256 / 10n ** 18n, // large but buffer-multiplication-safe + relative: 10n ** 18n, // 100% + allocation: 0n +} + +let marketCounter = 0 + +export const resetMarketCounter = () => { + 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 +}): VaultV2MarketData => { + const totalSupplyAssets = parseUnits('100000', 6) + const totalBorrowAssets = MathLib.wMulDown(totalSupplyAssets, opts.utilization) + return { + id: makeMarketId(), + capId: makeMarketId(), + params: opts.params ?? makeMarketParams(), + state: { + totalSupplyAssets, + totalSupplyShares: totalSupplyAssets * 1_000_000n, // 1:1 ratio simplified + totalBorrowAssets, + totalBorrowShares: totalBorrowAssets * 1_000_000n + }, + // 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 + } +} + +export const makeVaultData = ( + markets: VaultV2MarketData[], + overrides: Partial> = {} +): VaultV2Data => ({ + vaultAddress: VAULT, + adapterAddress: ADAPTER, + 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, + ...overrides +}) 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..476cf800 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategy-config.test.ts @@ -0,0 +1,74 @@ +import { getAddress } from 'viem' +import { afterEach, describe, expect, it } from 'vitest' + +import { + 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) + }) +}) 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/tsconfig.json b/bots/vault-v2-reallocation/tsconfig.json new file mode 100644 index 00000000..9863d08e --- /dev/null +++ b/bots/vault-v2-reallocation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript-config/base", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test", "scripts"], + "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..1be7d9fc --- /dev/null +++ b/bots/vault-v2-reallocation/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'vault-v2-reallocation' + } +}) diff --git a/knip.json b/knip.json index b2beef30..11ed157a 100644 --- a/knip.json +++ b/knip.json @@ -26,6 +26,7 @@ "bots/vault-v1-reallocation": { "entry": ["src/index.ts", "scripts/probe-live-lens.ts"] }, + "bots/vault-v2-reallocation": {}, "bots/midnight-crossed-books": { "ignore": ["src/infrastructure/*/generated/**"] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 399389ec..ccae0b84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,6 +455,52 @@ 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)) + '@morpho-org/morpho-ts': + specifier: 'catalog:' + version: 2.8.0 + '@repo/bot-kit': + specifier: workspace:* + version: link:../../packages/bot-kit + '@repo/utils': + specifier: workspace:* + version: link:../../packages/utils + 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' From ceec9ea79904e18160ffc3e4aa900a01c0cf5fa4 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 10:05:13 -0500 Subject: [PATCH 03/14] ci(vault-v2-reallocation): operator surface and deploy registration Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy-bot.yml | 3 +- .github/workflows/deploy-production.yml | 43 +++ .github/workflows/deploy-staging.yml | 8 + CLAUDE.md | 6 +- bots/vault-v2-reallocation/Dockerfile | 32 ++ bots/vault-v2-reallocation/README.md | 153 ++++++++ bots/vault-v2-reallocation/docker-compose.yml | 44 +++ .../scripts/deploy-railway.ts | 327 ++++++++++++++++++ 8 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 bots/vault-v2-reallocation/Dockerfile create mode 100644 bots/vault-v2-reallocation/README.md create mode 100644 bots/vault-v2-reallocation/docker-compose.yml create mode 100644 bots/vault-v2-reallocation/scripts/deploy-railway.ts diff --git a/.github/workflows/deploy-bot.yml b/.github/workflows/deploy-bot.yml index a5e1a875..1b755fbe 100644 --- a/.github/workflows/deploy-bot.yml +++ b/.github/workflows/deploy-bot.yml @@ -11,7 +11,7 @@ on: workflow_call: inputs: bot: - description: Which bot to deploy (blue-liq | vault-v1-realloc | midnight-liq | crossed-books) + description: Which bot to deploy (blue-liq | vault-v1-realloc | vault-v2-realloc | midnight-liq | crossed-books) required: true type: string stage: @@ -70,6 +70,7 @@ jobs: case "$BOT" in blue-liq) pkg='@morpho-org/blue-liquidation' ;; vault-v1-realloc) pkg='@morpho-org/vault-v1-reallocation' ;; + vault-v2-realloc) pkg='@morpho-org/vault-v2-reallocation' ;; midnight-liq) pkg='@morpho-org/midnight-liquidation' ;; crossed-books) pkg='@morpho-org/midnight-crossed-books' ;; *) echo "Unknown bot: $BOT" >&2; exit 1 ;; diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index c4535657..45f677a1 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -20,6 +20,7 @@ on: options: - blue-liq - vault-v1-realloc + - vault-v2-realloc - midnight-liq - crossed-books - quoter-bot @@ -42,6 +43,7 @@ jobs: # Output keys use underscores — a `-` in an Actions expression is the subtraction operator. blue_liq: ${{ steps.pick.outputs.blue_liq }} vault_v1_realloc: ${{ steps.pick.outputs.vault_v1_realloc }} + vault_v2_realloc: ${{ steps.pick.outputs.vault_v2_realloc }} midnight_liq: ${{ steps.pick.outputs.midnight_liq }} crossed_books: ${{ steps.pick.outputs.crossed_books }} quoter_bot: ${{ steps.pick.outputs.quoter_bot }} @@ -65,6 +67,7 @@ jobs: { echo "blue_liq=$(has blue-liq)" echo "vault_v1_realloc=$(has vault-v1-realloc)" + echo "vault_v2_realloc=$(has vault-v2-realloc)" echo "midnight_liq=$(has midnight-liq)" echo "crossed_books=$(has crossed-books)" echo "quoter_bot=$(has quoter-bot)" @@ -94,6 +97,16 @@ jobs: stage: production ref: ${{ github.sha }} + Vault-v2-realloc: + needs: Select + if: ${{ needs.Select.outputs.vault_v2_realloc == 'true' }} + uses: ./.github/workflows/deploy-bot.yml + secrets: inherit + with: + bot: vault-v2-realloc + stage: production + ref: ${{ github.sha }} + Midnight: needs: Select if: ${{ needs.Select.outputs.midnight_liq == 'true' }} @@ -167,6 +180,36 @@ jobs: gh release create "$tag" --target "$SHA" --title "$tag" --generate-notes \ ${prev:+--notes-start-tag "$prev"} + Release-vault-v2-realloc: + # A skipped/failed Vault-v2-realloc deploy skips this job too, so a bot is never tagged unless + # it deployed. + needs: Vault-v2-realloc + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Create release + env: + BOT: vault-v2-realloc + SHA: ${{ github.sha }} + run: | + set -euo pipefail + date="$(date -u +%Y.%m.%d)" + n=$(( $(git tag -l "${BOT}-${date}-*" | wc -l) + 1 )) + tag="${BOT}-${date}-${n}" + # `|| true` guards against SIGPIPE aborting the job under `set -o pipefail` (git is + # killed when head closes the pipe early once there are many tags). + prev="$(git tag -l "${BOT}-*" --sort=-version:refname | head -n 1 || true)" + echo "Creating release $tag (previous: ${prev:-none})" + gh release create "$tag" --target "$SHA" --title "$tag" --generate-notes \ + ${prev:+--notes-start-tag "$prev"} + Release-vault-v1-realloc: # A skipped/failed Vault-v1-realloc deploy skips this job too, so a bot is never tagged unless it # deployed. diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index b9438b1e..fba65529 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -33,6 +33,14 @@ jobs: stage: staging ref: ${{ github.sha }} + vault-v2-realloc: + uses: ./.github/workflows/deploy-bot.yml + secrets: inherit + with: + bot: vault-v2-realloc + stage: staging + ref: ${{ github.sha }} + midnight-liq: uses: ./.github/workflows/deploy-bot.yml secrets: inherit 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..7b8594c9 --- /dev/null +++ b/bots/vault-v2-reallocation/README.md @@ -0,0 +1,153 @@ +# 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). + +**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. Re-check the EOA's allocator role (`allocator.missing_role` + skip while absent — a pending + grant never crash-loops the bot, and a fresh grant is picked up without restart). +3. Fetch a block-pinned RPC snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` + (which also proves the address is a factory-made VaultV2), plus per-id + `absoluteCap`/`relativeCap`/`allocation` reads for every market id, the adapter id, and each + collateral id. No Morpho API dependency. +4. Run the strategy — a pure function of that snapshot, emitting **exact-amount deltas** + (`{allocations, deallocations}`); legs need not balance — the difference flows through the + vault's idle balance: + - **`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; allocations top up from idle (`ALLOW_IDLE_REALLOCATION`). 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, each measured against the on-chain `allocation(id)` the +contract enforces (not accrued position assets), scaled by a 99.99% buffer so interest accrual +between read and mined execution can't push a leg over cap. A binding aggregate cap shrinks the +plan instead of producing a sim-revert loop. + +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 MorphoMarketV1 adapter per vault** — startup and every fetch fail loud otherwise. + `forceDeallocate`, liquidity adapters, gates, MetaMorpho (VaultV1) adapters, and + `MorphoMarketV1AdapterV2` are out of scope. +- **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) + +```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). CI re-ships +already-provisioned services with `DEPLOY_ONLY=1` on merge to main (staging) and via the +`release-vault-v2-realloc` PR label (production). + +## 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`, +`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, 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); `DRY_RUN` against a live RPC +is the end-to-end check. diff --git a/bots/vault-v2-reallocation/docker-compose.yml b/bots/vault-v2-reallocation/docker-compose.yml new file mode 100644 index 00000000..a64dc66f --- /dev/null +++ b/bots/vault-v2-reallocation/docker-compose.yml @@ -0,0 +1,44 @@ +# 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. +services: + bot-mainnet: + build: + context: ../.. + dockerfile: bots/vault-v2-reallocation/Dockerfile + environment: + CHAIN_ID: '1' + RPC_URL: ${RPC_URL_1:?set RPC_URL_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:-} + # 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} + 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:-} + 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/scripts/deploy-railway.ts b/bots/vault-v2-reallocation/scripts/deploy-railway.ts new file mode 100644 index 00000000..9601c42e --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/deploy-railway.ts @@ -0,0 +1,327 @@ +/** + * Reproducible, idempotent deployment of the multi-chain vault-v2-reallocation system to a Railway + * project: one `bot-` runner per chain (see CHAINS below). All data comes over RPC, so + * there is no Postgres or indexer service to provision. + * + * Runs anywhere with the `railway` CLI installed and authenticated. The target project is supplied + * entirely via env vars — no project identifier is baked into this (open-source) file: + * - RAILWAY_PROJECT_ID (required) selects the project; RAILWAY_ENVIRONMENT defaults to `production`. + * - CI / unattended: set RAILWAY_TOKEN (a project token scoped to that project / environment). + * - Local: an interactive `railway login` session; the script links the project by id. + * + * Per-chain env vars are chainId-suffixed (endpoints/whitelists differ per chain): + * - RPC_URL_ (required per chain) + * - VAULT_WHITELIST_ (required per chain) — comma-separated VaultV2 vaults + * - REALLOCATOR_PRIVATE_KEY_ (per chain) OR a shared REALLOCATOR_PRIVATE_KEY fallback; + * the EOA must hold the allocator role on every whitelisted vault + * - STRATEGY_ (optional; defaults to equalize-utilizations) + * - DRY_RUN_ (optional; defaults to true — flip to false once the logged + * reallocation.dry_run plans look right) + * - BETTERSTACK_HEARTBEAT_URL_ (optional) + * + * RAILWAY_PROJECT_ID=… RPC_URL_1=… VAULT_WHITELIST_1=0x… REALLOCATOR_PRIVATE_KEY=0x… \ + * pnpm --filter @morpho-org/vault-v2-reallocation run deploy:railway + * + * 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). + * + * Idempotent: existing services / variables are reused; each run redeploys every bot and + * re-synchronizes STRATEGY / DRY_RUN / VAULT_WHITELIST from this run's inputs. + * + * Secret hygiene: secrets (per-chain RPC_URL, REALLOCATOR_PRIVATE_KEY) are piped to + * `railway variable set --stdin` so their values never appear in argv; on failure we surface only + * the variable key, never its value; variable values are never logged. + */ +import { delay, tryCatch } from '@repo/utils' +import { $ } from 'execa' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +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)), '..', '..', '..') + +type Env = Record +type RailwayService = { id: string; name: string } + +function required(env: Env, name: string): string { + const value = env[name] + if (!value || !value.trim()) throw new Error(`Missing required env var: ${name}`) + return value.trim() +} + +// 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. +function serviceName(productionName: string): string { + return ENVIRONMENT === 'production' + ? productionName + : `${ENVIRONMENT}-${productionName.toLowerCase()}` +} + +function isRecord(value: unknown): value is Record { + return 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]'). +function str(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +// Surface a failed `railway` command's stderr so failures are actionable. Safe for non-secret +// commands; never used on setSecret. +function stderrOf(error: unknown): string { + if (isRecord(error) && 'stderr' in error) { + const s = (error as { stderr: unknown }).stderr + if (typeof s === 'string' && s.trim()) return s.trim() + if (s instanceof Uint8Array) { + const text = Buffer.from(s).toString('utf8').trim() + if (text) return text + } + } + return error instanceof Error ? error.message : String(error) +} + +function assertPrivateKey(key: string): void { + if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { + throw new Error('REALLOCATOR_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') + } +} + +function parseServices(raw: string): RailwayService[] { + const { data } = tryCatch(() => JSON.parse(raw) as unknown) + const rows = Array.isArray(data) + ? data + : isRecord(data) && Array.isArray(data.services) + ? data.services + : [] + return rows + .filter(isRecord) + .map(row => ({ id: str(row.id), name: str(row.name) || str(row.serviceName) })) + .filter(service => service.name) +} + +function parseLatestStatus(raw: string): string { + const { data } = tryCatch(() => JSON.parse(raw) as unknown) + const rows = Array.isArray(data) + ? data + : isRecord(data) && Array.isArray(data.deployments) + ? data.deployments + : [] + const latest = rows.filter(isRecord)[0] + return latest ? str(latest.status) || 'UNKNOWN' : 'UNKNOWN' +} + +async function assertCli(): Promise { + const { error } = await tryCatch($`railway --version`) + if (error) + throw new Error('Railway CLI not found. Install it: https://docs.railway.com/guides/cli') +} + +// `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. +async function ensureContext(): 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 Error( + `Failed to link ${PROJECT_ID} (${ENVIRONMENT}). Set RAILWAY_TOKEN or run \`railway login\`.` + ) + } + console.log(`Linked project ${PROJECT_ID} (${ENVIRONMENT}).`) +} + +async function listServices(): Promise { + const { data, error } = await tryCatch($`railway service list --json`.then(r => r.stdout)) + return error || typeof data !== 'string' ? [] : parseServices(data) +} + +async function ensureService(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 Error(`Failed to create service ${name}: ${stderrOf(error)}`) +} + +// Non-secret variable. `kv` is a single "KEY=VALUE" arg; only the key is logged. +async function setVar(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 Error(`Failed to set ${key} on ${service}: ${stderrOf(error)}`) + console.log(`Set ${key} on ${service}.`) +} + +// Secret variable: value piped via stdin (never argv), `--json` omitted (it echoes raw values). +async function setSecret(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 Error(`Failed to set ${key} on ${service}`) + console.log(`Set ${key} on ${service} (secret).`) +} + +async function deployService(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 Error(`Failed to start deploy for ${service}: ${stderrOf(error)}`) +} + +async function latestStatus(service: string): Promise { + // -e/-p explicit for the same reason as deployService: don't depend on ambient link state. + const args = [ + 'railway', + 'deployment', + 'list', + '-s', + service, + '-e', + ENVIRONMENT, + '-p', + PROJECT_ID, + '--limit', + '1', + '--json' + ] + const { data, error } = await tryCatch($(args[0] ?? 'railway', args.slice(1)).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. +async function waitForDeploy( + 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 MetaMorpho vault), so a crash-looping service must never read +// as a green deploy. +const badStatus = (status: string) => + status === 'FAILED' || status === 'TIMEOUT' || status === 'CRASHED' + +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 CI uses: the +// per-bot-per-stage GitHub 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) +} + +// 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. +function suffixed(name: string, chainId: number): string | undefined { + return process.env[`${name}_${chainId}`]?.trim() || undefined +} +function requiredSuffixed(name: string, chainId: number): string { + const value = suffixed(name, chainId) + if (!value) throw new Error(`Missing required env var: ${name}_${chainId}`) + return value +} + +// 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 Error( + `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 + } +}) + +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) + 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 From 50b9122b5a359c418cb5f24025561766fd7887ad Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 10:17:47 -0500 Subject: [PATCH 04/14] feat(vault-v2-reallocation): support both morpho market adapter generations All live VaultV2s use the MorphoMarketV1AdapterV2 contract; both generations take identical allocate/deallocate calldata and cap ids, so vault-data normalizes the two SDK adapter shapes into one market list. Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/README.md | 7 +- bots/vault-v2-reallocation/src/vault-data.ts | 125 ++++++++++++------- 2 files changed, 87 insertions(+), 45 deletions(-) diff --git a/bots/vault-v2-reallocation/README.md b/bots/vault-v2-reallocation/README.md index 7b8594c9..d9f4571d 100644 --- a/bots/vault-v2-reallocation/README.md +++ b/bots/vault-v2-reallocation/README.md @@ -48,9 +48,10 @@ adapter (see `@repo/bot-kit`'s `Policy.multicall`). Assumptions and posture: -- **Exactly one MorphoMarketV1 adapter per vault** — startup and every fetch fail loud otherwise. - `forceDeallocate`, liquidity adapters, gates, MetaMorpho (VaultV1) adapters, and - `MorphoMarketV1AdapterV2` are out of scope. +- **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. - **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; diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts index 70cef027..445e1029 100644 --- a/bots/vault-v2-reallocation/src/vault-data.ts +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -1,8 +1,10 @@ -import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { InputMarketParams, MarketParams } from '@morpho-org/blue-sdk' import type { Address, Client, Hex } from 'viem' import { AccrualVaultV2MorphoMarketV1Adapter, + AccrualVaultV2MorphoMarketV1AdapterV2, + SharesMath, VaultV2MorphoMarketV1Adapter } from '@morpho-org/blue-sdk' import { fetchAccrualVaultV2, vaultV2Abi } from '@morpho-org/blue-sdk-viem' @@ -56,6 +58,50 @@ export type VaultV2Data = { marketsData: VaultV2MarketData[] } +type MarketAdapter = AccrualVaultV2MorphoMarketV1Adapter | AccrualVaultV2MorphoMarketV1AdapterV2 + +type AdapterMarket = { + params: MarketParams + state: MarketState + vaultAssets: bigint + rateAtTarget: bigint +} + +// Both Morpho Blue market adapter generations take the same abi-encoded market params in +// allocate/deallocate and derive identical cap ids; they differ only in how the SDK models their +// positions (AccrualPosition list vs supplyShares per market id). Normalize to one shape. +const normalizeAdapterMarkets = (adapter: MarketAdapter, now: bigint): AdapterMarket[] => { + if (adapter instanceof AccrualVaultV2MorphoMarketV1Adapter) { + return adapter.marketParamsList.map(params => { + const position = adapter.positions.find(candidate => candidate.marketId === params.id) + if (position === undefined) { + throw new InvalidVaultError(`adapter position missing for market ${params.id}`) + } + const accrued = position.accrueInterest(now) + return { + params, + state: accrued.market, + vaultAssets: accrued.supplyAssets, + rateAtTarget: accrued.market.rateAtTarget ?? 0n + } + }) + } + return adapter.markets.map(market => { + const accrued = market.accrueInterest(now) + return { + params: accrued.params, + state: accrued, + vaultAssets: SharesMath.toAssets( + adapter.supplyShares[accrued.id] ?? 0n, + accrued.totalSupplyAssets, + accrued.totalSupplyShares, + 'Down' + ), + rateAtTarget: accrued.rateAtTarget ?? 0n + } + }) +} + const readCap = async ( client: Client, vault: Address, @@ -93,8 +139,9 @@ const readCap = async ( * snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` (which also proves the * address is a factory-made VaultV2), plus the per-id cap/allocation reads the SDK fetcher does not * cover for a regular adapter (market ids, the adapter id, and each distinct collateral id). - * Throws {@link InvalidVaultError} unless the vault has exactly one adapter and it is a - * MorphoMarketV1 adapter. Throws on any failed read — the tick catches per vault. + * Throws {@link InvalidVaultError} unless the vault has exactly one adapter and it is a Morpho Blue + * market adapter (either adapter-contract generation). Throws on any failed read — the tick + * catches per vault. */ export const fetchVaultV2Data = async ( client: Client, @@ -104,28 +151,27 @@ export const fetchVaultV2Data = async ( const vaultV2 = await fetchAccrualVaultV2(vault, client, { chainId, blockNumber }) const marketAdapters = vaultV2.accrualAdapters.filter( - (adapter): adapter is AccrualVaultV2MorphoMarketV1Adapter => - adapter instanceof AccrualVaultV2MorphoMarketV1Adapter + (adapter): adapter is MarketAdapter => + adapter instanceof AccrualVaultV2MorphoMarketV1Adapter || + adapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2 ) if (vaultV2.adapters.length !== 1 || marketAdapters.length !== 1) { throw new InvalidVaultError( - `vault ${vault} must have exactly one MorphoMarketV1 adapter; found ` + - `${vaultV2.adapters.length} adapter(s) of which ${marketAdapters.length} are MorphoMarketV1` + `vault ${vault} must have exactly one Morpho Blue market adapter; found ` + + `${vaultV2.adapters.length} adapter(s) of which ${marketAdapters.length} qualify` ) } const adapter = marketAdapters[0]! const adapterAddress = getAddress(adapter.address) const now = BigInt(Math.floor(Date.now() / 1000)) - - const positionByMarketId = new Map( - adapter.positions.map(position => [position.marketId, position]) - ) + const adapterMarkets = normalizeAdapterMarkets(adapter, now) const collateralTokens = [ - ...new Set(adapter.marketParamsList.map(params => getAddress(params.collateralToken))) + ...new Set(adapterMarkets.map(({ params }) => getAddress(params.collateralToken))) ] const [adapterCap, collateralCapList, marketsData] = await Promise.all([ + // Both adapter generations derive identical "this"/"collateralToken"/"this/marketParams" ids. readCap(client, vault, VaultV2MorphoMarketV1Adapter.adapterId(adapterAddress), blockNumber), Promise.all( collateralTokens.map(async token => ({ @@ -139,37 +185,32 @@ export const fetchVaultV2Data = async ( })) ), Promise.all( - adapter.marketParamsList.map(async (params): Promise => { - const position = positionByMarketId.get(params.id) - if (position === undefined) { - throw new InvalidVaultError( - `vault ${vault} adapter position missing for market ${params.id}` - ) - } - const capId = VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, params) - const cap = await readCap(client, vault, capId, blockNumber) - const accrued = position.accrueInterest(now) - return { - id: params.id, - capId, - params: { - loanToken: params.loanToken, - collateralToken: params.collateralToken, - oracle: params.oracle, - irm: params.irm, - lltv: params.lltv - }, - state: { - totalSupplyAssets: accrued.market.totalSupplyAssets, - totalSupplyShares: accrued.market.totalSupplyShares, - totalBorrowAssets: accrued.market.totalBorrowAssets, - totalBorrowShares: accrued.market.totalBorrowShares - }, - cap, - vaultAssets: accrued.supplyAssets, - rateAtTarget: accrued.market.rateAtTarget ?? 0n + adapterMarkets.map( + async ({ params, state, vaultAssets, rateAtTarget }): Promise => { + const capId = VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, params) + const cap = await readCap(client, vault, capId, blockNumber) + return { + id: params.id, + capId, + params: { + loanToken: params.loanToken, + collateralToken: params.collateralToken, + oracle: params.oracle, + irm: params.irm, + lltv: params.lltv + }, + state: { + totalSupplyAssets: state.totalSupplyAssets, + totalSupplyShares: state.totalSupplyShares, + totalBorrowAssets: state.totalBorrowAssets, + totalBorrowShares: state.totalBorrowShares + }, + cap, + vaultAssets, + rateAtTarget + } } - }) + ) ) ]) From ef495f923968b4677fc02148f9cb00a7eb326112 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 10:48:11 -0500 Subject: [PATCH 05/14] fix(vault-v2-reallocation): mirror v1 review fixes Adopt bot-kit simulateCall (local simulate helper dropped), accrue snapshots to the pinned block's timestamp, exclude non-AdaptiveCurve markets from apy-range (a zero rateAtTarget degenerates the inversion and would drain the position on sim-passing calldata), and clamp equalize's target utilization at 100% for bad-debt states. The V1 role-gate widening is deliberately NOT mirrored: VaultV2.allocate requires isAllocator strictly. Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/src/index.ts | 12 ++++-- bots/vault-v2-reallocation/src/runner/tick.ts | 13 +++++- bots/vault-v2-reallocation/src/simulate.ts | 31 -------------- .../src/strategies/apy-range.ts | 10 +++-- .../src/strategies/equalize-utilizations.ts | 6 ++- bots/vault-v2-reallocation/src/vault-data.ts | 40 ++++++++++++++----- .../test/strategies/apy-range.test.ts | 20 ++++++++++ .../strategies/equalize-utilizations.test.ts | 21 ++++++++++ .../test/strategies/helpers.ts | 4 +- 9 files changed, 104 insertions(+), 53 deletions(-) delete mode 100644 bots/vault-v2-reallocation/src/simulate.ts diff --git a/bots/vault-v2-reallocation/src/index.ts b/bots/vault-v2-reallocation/src/index.ts index ebcd508a..0eb0e780 100644 --- a/bots/vault-v2-reallocation/src/index.ts +++ b/bots/vault-v2-reallocation/src/index.ts @@ -13,7 +13,8 @@ import { DEFAULT_MAX_DATA_BYTES, DEFAULT_MAX_GAS_LIMIT, initialFees, - railwayContext + railwayContext, + simulateCall } from '@repo/bot-kit' import { ensureError, tryCatch } from '@repo/utils' import { getAbiItem, toFunctionSelector } from 'viem' @@ -23,7 +24,6 @@ import { loadConfig } from './config' import { encodeReallocation } from './encode' import { InvalidVaultError } from './invalid-vault.error' import { runTick } from './runner/tick' -import { simulateReallocate } from './simulate' import { createStrategy } from './strategies' import { revertReason } from './tx-error' import { fetchVaultV2Data } from './vault-data' @@ -107,7 +107,9 @@ async function main() { }) // The allocator role is probed non-fatally: a pending grant must not crash-loop the bot; the tick - // re-checks and resumes on its own. + // re-checks and resumes on its own. Unlike MetaMorpho V1's onlyAllocatorRole, VaultV2.allocate + // requires isAllocator[msg.sender] strictly — curator/owner do NOT implicitly qualify, so the + // check is deliberately narrower than the V1 bot's. const isAllocator = (vault: Address) => readContract(client, { address: vault, @@ -165,7 +167,9 @@ async function main() { strategy, encodeReallocation: (vaultData, reallocation) => encodeReallocation(vaultData.adapterAddress, reallocation), - simulate: (vault, data) => simulateReallocate(client, { vault, eoa, data }), + // 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) await queue.submit({ diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts index afef34f1..a836c526 100644 --- a/bots/vault-v2-reallocation/src/runner/tick.ts +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -1,9 +1,8 @@ -import type { Logger } from '@repo/bot-kit' +import type { Logger, SimulateResult } from '@repo/bot-kit' import type { Address, Hex } from 'viem' import { tryCatch } from '@repo/utils' -import type { SimulateResult } from '../simulate' import type { Reallocation, Strategy } from '../strategies' import type { VaultV2Data } from '../vault-data' @@ -63,6 +62,16 @@ const processVault = async ( } const vaultData = await deps.fetchVault(vault, deps.chainHead) + + // Surfaced because `apy-range` excludes these outright — the curve inversion it relies on needs a + // real AdaptiveCurveIRM `rateAtTarget` (`equalize-utilizations` keeps them). + const foreignIrmMarkets = vaultData.marketsData + .filter(marketData => !marketData.isAdaptiveCurve) + .map(marketData => marketData.id) + if (foreignIrmMarkets.length > 0) { + deps.logger.debug('market.non_adaptive_curve', { vault, markets: foreignIrmMarkets }) + } + const reallocation = deps.strategy(vaultData) if (!reallocation) return counters.reallocations_found++ diff --git a/bots/vault-v2-reallocation/src/simulate.ts b/bots/vault-v2-reallocation/src/simulate.ts deleted file mode 100644 index 23b8df3e..00000000 --- a/bots/vault-v2-reallocation/src/simulate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Address, Client, Hex } from 'viem' - -import { tryCatch } from '@repo/utils' -import { BaseError } from 'viem' -import { call } from 'viem/actions' - -export type SimulateResult = { - /** `ok` — the reallocate succeeds from this EOA, safe to broadcast. `revert` — do not send. */ - status: 'ok' | 'revert' - reason?: string -} - -/** - * Simulates the real reallocation — `vault.multicall([deallocate…, allocate…])` from the allocator - * EOA, byte-for-byte what gets broadcast. Any revert (role revoked, cap exceeded, insufficient - * idle, market no longer enabled) means do not broadcast; the tick gates on `ok` only. No signer; - * never sends. - */ -export const simulateReallocate = async ( - client: Client, - params: { vault: Address; eoa: Address; data: Hex } -): Promise => { - const { error } = await tryCatch( - call(client, { account: params.eoa, to: params.vault, data: params.data, value: 0n }) - ) - if (!error) return { status: 'ok' } - return { - status: 'revert', - reason: error instanceof BaseError ? error.shortMessage : error.message - } -} diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index 296da756..193aa223 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -33,13 +33,17 @@ const { min } = MathLib * utilization bounds via the AdaptiveCurveIRM inverse, deallocates from markets below range, * allocates into markets above range. Allocations may exceed deallocations by up to the vault's * idle balance; excess deallocations park in idle unless `allowIdleReallocation` is off, in which - * case they are clamped to the allocation total. Assumes every market uses the AdaptiveCurveIRM. - * Allocations respect the market, adapter-level, and collateral-level caps. + * case they are clamped to the allocation total. + * + * 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). Allocations respect the market, adapter-level, and + * collateral-level caps. */ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { return vaultData => { const vault = vaultData.vaultAddress - const marketsData = vaultData.marketsData + const marketsData = vaultData.marketsData.filter(marketData => marketData.isAdaptiveCurve) let totalAmountToDeallocate = 0n let totalAmountToAllocate = 0n diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts index 2a18add9..63b00424 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -19,7 +19,7 @@ type EqualizeUtilizationsConfig = { minUtilizationDeltaBips: (vault: Address) => number } -const { min, wDivDown } = MathLib +const { min, WAD, wDivDown } = MathLib /** * Converges every market toward the vault-wide average utilization @@ -45,7 +45,9 @@ export const createEqualizeUtilizationsStrategy = ( // 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 targetUtilization = wDivDown(totalBorrow, totalSupply) + // Aggregate utilization exceeds 100% in bad-debt states; sizing deallocations toward a >100% + // target asks for more than the markets hold, so every resulting plan reverts. + const targetUtilization = min(wDivDown(totalBorrow, totalSupply), WAD) let totalAmountToDeallocate = 0n let totalAmountToAllocate = 0n diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts index 445e1029..03c474bd 100644 --- a/bots/vault-v2-reallocation/src/vault-data.ts +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -4,12 +4,13 @@ import type { Address, Client, Hex } from 'viem' import { AccrualVaultV2MorphoMarketV1Adapter, AccrualVaultV2MorphoMarketV1AdapterV2, + getChainAddresses, SharesMath, VaultV2MorphoMarketV1Adapter } from '@morpho-org/blue-sdk' import { fetchAccrualVaultV2, vaultV2Abi } from '@morpho-org/blue-sdk-viem' -import { getAddress } from 'viem' -import { readContract } from 'viem/actions' +import { getAddress, isAddressEqual } from 'viem' +import { getBlock, readContract } from 'viem/actions' import { InvalidVaultError } from './invalid-vault.error' @@ -42,12 +43,18 @@ export type VaultV2MarketData = { 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 } export type VaultV2Data = { vaultAddress: Address adapterAddress: Address - /** The vault's total assets, interest accrued to now. */ + /** The vault's total assets, interest accrued to the pinned block's timestamp. */ totalAssets: bigint /** The vault's un-allocated asset balance (deallocate parks here; allocate draws from here). */ idleAssets: bigint @@ -58,6 +65,16 @@ export type VaultV2Data = { marketsData: VaultV2MarketData[] } +/** + * 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) + type MarketAdapter = AccrualVaultV2MorphoMarketV1Adapter | AccrualVaultV2MorphoMarketV1AdapterV2 type AdapterMarket = { @@ -70,14 +87,14 @@ type AdapterMarket = { // Both Morpho Blue market adapter generations take the same abi-encoded market params in // allocate/deallocate and derive identical cap ids; they differ only in how the SDK models their // positions (AccrualPosition list vs supplyShares per market id). Normalize to one shape. -const normalizeAdapterMarkets = (adapter: MarketAdapter, now: bigint): AdapterMarket[] => { +const normalizeAdapterMarkets = (adapter: MarketAdapter, timestamp: bigint): AdapterMarket[] => { if (adapter instanceof AccrualVaultV2MorphoMarketV1Adapter) { return adapter.marketParamsList.map(params => { const position = adapter.positions.find(candidate => candidate.marketId === params.id) if (position === undefined) { throw new InvalidVaultError(`adapter position missing for market ${params.id}`) } - const accrued = position.accrueInterest(now) + const accrued = position.accrueInterest(timestamp) return { params, state: accrued.market, @@ -87,7 +104,7 @@ const normalizeAdapterMarkets = (adapter: MarketAdapter, now: bigint): AdapterMa }) } return adapter.markets.map(market => { - const accrued = market.accrueInterest(now) + const accrued = market.accrueInterest(timestamp) return { params: accrued.params, state: accrued, @@ -164,8 +181,10 @@ export const fetchVaultV2Data = async ( const adapter = marketAdapters[0]! const adapterAddress = getAddress(adapter.address) - const now = BigInt(Math.floor(Date.now() / 1000)) - const adapterMarkets = normalizeAdapterMarkets(adapter, now) + // Accrue to the pinned block's timestamp, not wall clock, so the snapshot is coherent and + // reproducible against the pinned reads. + const { timestamp } = await getBlock(client, { blockNumber }) + const adapterMarkets = normalizeAdapterMarkets(adapter, timestamp) const collateralTokens = [ ...new Set(adapterMarkets.map(({ params }) => getAddress(params.collateralToken))) ] @@ -207,7 +226,8 @@ export const fetchVaultV2Data = async ( }, cap, vaultAssets, - rateAtTarget + rateAtTarget, + isAdaptiveCurve: isAdaptiveCurveMarket(params.irm, rateAtTarget, chainId) } } ) @@ -217,7 +237,7 @@ export const fetchVaultV2Data = async ( return { vaultAddress: vault, adapterAddress, - totalAssets: vaultV2.accrueInterest(now).vault._totalAssets, + totalAssets: vaultV2.accrueInterest(timestamp).vault._totalAssets, idleAssets: vaultV2.assetBalance, adapterCap, collateralCaps: Object.fromEntries(collateralCapList.map(({ token, cap }) => [token, cap])), diff --git a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts index 2b4fd186..777bd44c 100644 --- a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -194,6 +194,26 @@ describe('createApyRangeStrategy', () => { }) }) + 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() diff --git a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts index ac12a707..395dfe50 100644 --- a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -58,6 +58,27 @@ describe('createEqualizeUtilizationsStrategy', () => { ).toBeUndefined() }) + 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])) + if (result) { + const deallocation = result.deallocations[0] + expect(deallocation?.assets ?? 0n).toBeLessThanOrEqual(coldMarket.vaultAssets) + } + }) + it('returns undefined when nothing is borrowed anywhere', () => { const strategy = makeStrategy() const market = makeMarket({ diff --git a/bots/vault-v2-reallocation/test/strategies/helpers.ts b/bots/vault-v2-reallocation/test/strategies/helpers.ts index ac8f2ba6..44e02d63 100644 --- a/bots/vault-v2-reallocation/test/strategies/helpers.ts +++ b/bots/vault-v2-reallocation/test/strategies/helpers.ts @@ -49,6 +49,7 @@ export const makeMarket = (opts: { cap?: Partial rateAtTarget: bigint params?: InputMarketParams + isAdaptiveCurve?: boolean }): VaultV2MarketData => { const totalSupplyAssets = parseUnits('100000', 6) const totalBorrowAssets = MathLib.wMulDown(totalSupplyAssets, opts.utilization) @@ -66,7 +67,8 @@ export const makeMarket = (opts: { // divergence override `cap.allocation` explicitly. cap: { ...UNLIMITED_CAP, allocation: opts.vaultAssets, ...opts.cap }, vaultAssets: opts.vaultAssets, - rateAtTarget: opts.rateAtTarget + rateAtTarget: opts.rateAtTarget, + isAdaptiveCurve: opts.isAdaptiveCurve ?? true } } From 9911fee1b92eddb3fa42191a4e614a068cfa4dcb Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 12:20:49 -0500 Subject: [PATCH 06/14] fix(vault-v2-reallocation): apply dual-model review findings Cap math corrected against the VaultV2 contract: relativeCap == WAD honored as the no-constraint sentinel (a fully-deployed vault previously zeroed its adapter pool and silently no-opped forever); headroom measured from the accrued position the adapter trues allocation(id) up to, with aggregate pools carrying every market's accrual drift; the plan's own deallocations (executed first) credit capacity back to the pools. Equalize now clamps allocations to deallocations + idle (allocate pulls from vault balance). Strategies restructured deallocate-first; legs carry the market id for log correlation; the tick surfaces a mid-run adapter swap as adapter.changed instead of opaque policy violations; policy evaluation deny-wraps any throw from the multicall check and gains a dirty-upper-bits padding test; stale V1 wording and README claims corrected. Verified live: a fully-deployed prod vault that previously produced no plan now emits an exactly-funded one. Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/README.md | 22 ++- bots/vault-v2-reallocation/src/config.ts | 7 +- bots/vault-v2-reallocation/src/index.ts | 1 + bots/vault-v2-reallocation/src/math.ts | 114 ++++++++---- bots/vault-v2-reallocation/src/runner/tick.ts | 22 +++ .../src/strategies/apy-range.ts | 167 ++++++++++-------- .../src/strategies/equalize-utilizations.ts | 142 +++++++++------ .../src/strategies/strategy.ts | 3 + bots/vault-v2-reallocation/src/vault-data.ts | 152 ++++++++-------- .../vault-v2-reallocation/test/encode.test.ts | 6 +- bots/vault-v2-reallocation/test/math.test.ts | 100 ++++++++++- .../test/runner/tick.test.ts | 19 +- .../test/strategies/apy-range.test.ts | 23 ++- .../strategies/equalize-utilizations.test.ts | 148 +++++++++++++++- .../test/strategies/helpers.ts | 3 +- packages/bot-kit/src/policy.ts | 5 +- packages/bot-kit/test/policy.test.ts | 12 ++ 17 files changed, 680 insertions(+), 266 deletions(-) diff --git a/bots/vault-v2-reallocation/README.md b/bots/vault-v2-reallocation/README.md index d9f4571d..6197b604 100644 --- a/bots/vault-v2-reallocation/README.md +++ b/bots/vault-v2-reallocation/README.md @@ -24,8 +24,11 @@ default 10 min) throttles the actual reallocation passes. Each pass, per whiteli `absoluteCap`/`relativeCap`/`allocation` reads for every market id, the adapter id, and each collateral id. No Morpho API dependency. 4. Run the strategy — a pure function of that snapshot, emitting **exact-amount deltas** - (`{allocations, deallocations}`); legs need not balance — the difference flows through the - vault's idle balance: + (`{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`. @@ -37,10 +40,12 @@ default 10 min) throttles the actual reallocation passes. Each pass, per whiteli (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, each measured against the on-chain `allocation(id)` the -contract enforces (not accrued position assets), scaled by a 99.99% buffer so interest accrual -between read and mined execution can't push a leg over cap. A binding aggregate cap shrinks the -plan instead of producing a sim-revert loop. +(`"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. 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 @@ -52,6 +57,11 @@ Assumptions and posture: `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; diff --git a/bots/vault-v2-reallocation/src/config.ts b/bots/vault-v2-reallocation/src/config.ts index 1e4b0403..25bc1aff 100644 --- a/bots/vault-v2-reallocation/src/config.ts +++ b/bots/vault-v2-reallocation/src/config.ts @@ -6,7 +6,7 @@ import { base, mainnet } from 'viem/chains' import { InvalidConfigError } from './invalid-config.error' -// Chains this bot supports. MetaMorpho vault addresses come from VAULT_WHITELIST and the Blue +// 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 = { @@ -53,7 +53,7 @@ export type Config = { reallocationIntervalMs: number minApyDeltaBips: number minUtilizationDeltaBips: number - /** Whether ApyRange may park excess liquidity in the vault's idle market. */ + /** 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 @@ -131,7 +131,8 @@ const isStrategyName = (value: string): value is StrategyName => * 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 answers the MetaMorpho V1 surface) are performed in `index.ts` once a client exists. + * 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, diff --git a/bots/vault-v2-reallocation/src/index.ts b/bots/vault-v2-reallocation/src/index.ts index 0eb0e780..261cc639 100644 --- a/bots/vault-v2-reallocation/src/index.ts +++ b/bots/vault-v2-reallocation/src/index.ts @@ -162,6 +162,7 @@ async function main() { vaults: config.vaultWhitelist, chainHead, isAllocator, + expectedAdapter: vault => adapterByVault[vault]?.[0], fetchVault: (vault, blockNumber) => fetchVaultV2Data(client, vault, { chainId: config.chainId, blockNumber }), strategy, diff --git a/bots/vault-v2-reallocation/src/math.ts b/bots/vault-v2-reallocation/src/math.ts index 885e7981..97ad2705 100644 --- a/bots/vault-v2-reallocation/src/math.ts +++ b/bots/vault-v2-reallocation/src/math.ts @@ -16,17 +16,25 @@ export const getUtilization = (state: MarketState): bigint => ? 0n : MathLib.wDivDown(state.totalBorrowAssets, state.totalSupplyAssets) +// 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 size to 0. const getWithdrawalToUtilization = (state: MarketState, targetUtilization: bigint): bigint => - MathLib.wMulDown( - state.totalSupplyAssets, - MathLib.WAD - MathLib.wDivDown(getUtilization(state), targetUtilization) - ) + targetUtilization === 0n + ? 0n + : MathLib.wMulDown( + state.totalSupplyAssets, + MathLib.WAD - MathLib.wDivDown(getUtilization(state), targetUtilization) + ) const getDepositToUtilization = (state: MarketState, targetUtilization: bigint): bigint => - MathLib.wMulDown( - state.totalSupplyAssets, - MathLib.wDivDown(getUtilization(state), targetUtilization) - MathLib.WAD - ) + targetUtilization === 0n + ? 0n + : MathLib.wMulDown( + state.totalSupplyAssets, + MathLib.wDivDown(getUtilization(state), targetUtilization) - MathLib.WAD + ) /** * Assets deallocatable from a market before its utilization would exceed `targetUtilization`, @@ -43,30 +51,38 @@ export const getWithdrawableAmount = ( ) /** - * Remaining deposit headroom under one cap id: min of the buffered absolute cap and the buffered - * relative cap (fraction of `totalAssets`) minus the id's on-chain `allocation` — the value the - * contract enforces both caps against (accrued position assets drift above it as interest accrues). + * 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, capBufferPercent: number ): bigint => { const buffer = percentToWad(capBufferPercent) const bufferedAbsolute = MathLib.wMulDown(cap.absolute, buffer) - const absoluteHeadroom = - bufferedAbsolute > cap.allocation ? bufferedAbsolute - cap.allocation : 0n + const absoluteHeadroom = bufferedAbsolute > basis ? bufferedAbsolute - basis : 0n + if (cap.relative === MathLib.WAD) return absoluteHeadroom const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), buffer) - const relativeHeadroom = - bufferedRelative > cap.allocation ? bufferedRelative - cap.allocation : 0n + const relativeHeadroom = bufferedRelative > basis ? bufferedRelative - basis : 0n return MathLib.min(absoluteHeadroom, relativeHeadroom) } +// 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.max(0n, 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 ({@link getCapHeadroom}). 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}. + * 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, @@ -76,13 +92,22 @@ export const getDepositableAmount = ( ): bigint => MathLib.min( getDepositToUtilization(marketData.state, targetUtilization), - getCapHeadroom(marketData.cap, totalAssets, capBufferPercent) + getCapHeadroom( + marketData.cap, + MathLib.max(marketData.cap.allocation, marketData.vaultAssets), + totalAssets, + capBufferPercent + ) ) /** * Shared deposit ceilings above the per-market caps: the adapter-level ("this") cap id and one pool - * per collateral cap id, all enforced on-chain against `allocation(id)`. Strategies draw legs - * through {@link takeFromPools} so a plan can never exceed an aggregate cap. + * 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 DepositPools = { adapter: bigint @@ -92,15 +117,44 @@ type DepositPools = { export const createDepositPools = ( vaultData: VaultV2Data, capBufferPercent: number -): DepositPools => ({ - adapter: getCapHeadroom(vaultData.adapterCap, vaultData.totalAssets, capBufferPercent), - byCollateral: new Map( - Object.entries(vaultData.collateralCaps).map(([token, cap]) => [ - getAddress(token), - getCapHeadroom(cap, vaultData.totalAssets, capBufferPercent) - ]) - ) -}) +): 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: getCapHeadroom( + vaultData.adapterCap, + vaultData.adapterCap.allocation + totalDrift, + vaultData.totalAssets, + capBufferPercent + ), + byCollateral: new Map( + Object.entries(vaultData.collateralCaps).map(([token, cap]) => [ + getAddress(token), + getCapHeadroom( + cap, + cap.allocation + (driftByCollateral.get(getAddress(token)) ?? 0n), + vaultData.totalAssets, + capBufferPercent + ) + ]) + ) + } +} + +/** Credits capacity freed by a deallocation leg (executed before every allocation leg). */ +export const creditPools = ( + pools: DepositPools, + collateralToken: Address, + amount: bigint +): void => { + const key = getAddress(collateralToken) + pools.adapter += amount + pools.byCollateral.set(key, (pools.byCollateral.get(key) ?? 0n) + amount) +} /** Clamps `amount` to the adapter and collateral pools, decrements both, and returns the clamp. */ export const takeFromPools = ( diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts index a836c526..2e75b83e 100644 --- a/bots/vault-v2-reallocation/src/runner/tick.ts +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -11,6 +11,12 @@ export type TickDeps = { chainHead: bigint /** Live allocator-role check; a vault without the role is skipped (and resumes once granted). */ isAllocator: (vault: Address) => Promise + /** + * 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 @@ -28,6 +34,7 @@ type TickCounters = { vaults: number skipped_inflight: number missing_role: number + adapter_changed: number reallocations_found: number sim_reverts: number dry_runs: number @@ -38,12 +45,14 @@ type TickCounters = { const summarize = (reallocation: Reallocation) => [ ...reallocation.deallocations.map(leg => ({ action: 'deallocate', + marketId: leg.marketId, collateralToken: leg.marketParams.collateralToken, lltv: leg.marketParams.lltv, assets: leg.assets })), ...reallocation.allocations.map(leg => ({ action: 'allocate', + marketId: leg.marketId, collateralToken: leg.marketParams.collateralToken, lltv: leg.marketParams.lltv, assets: leg.assets @@ -63,6 +72,18 @@ const processVault = async ( const vaultData = await deps.fetchVault(vault, deps.chainHead) + const expectedAdapter = deps.expectedAdapter(vault) + if (expectedAdapter !== undefined && vaultData.adapterAddress !== expectedAdapter) { + counters.adapter_changed++ + 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 + } + // Surfaced because `apy-range` excludes these outright — the curve inversion it relies on needs a // real AdaptiveCurveIRM `rateAtTarget` (`equalize-utilizations` keeps them). const foreignIrmMarkets = vaultData.marketsData @@ -110,6 +131,7 @@ export const runTick = async (deps: TickDeps): Promise => { vaults: deps.vaults.length, skipped_inflight: 0, missing_role: 0, + adapter_changed: 0, reallocations_found: 0, sim_reverts: 0, dry_runs: 0, diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index 193aa223..392a9e49 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -2,11 +2,13 @@ import type { Address, Hex } from 'viem' import { MathLib } from '@morpho-org/blue-sdk' +import type { VaultV2MarketData } from '../vault-data' import type { Reallocation, ReallocationAction, Strategy } from './strategy' import { apyToRate, createDepositPools, + creditPools, getDepositableAmount, getUtilization, getWithdrawableAmount, @@ -28,6 +30,29 @@ export type ApyRangeConfig = { const { min } = MathLib +/** Where a market's utilization sits relative to the utilization bounds its APY range implies. */ +const classifyMarket = ( + config: ApyRangeConfig, + vault: Address, + marketData: VaultV2MarketData +): { utilization: bigint; lowerBound: bigint; upperBound: bigint } => { + const apyRange = config.apyRange(vault, marketData.id) + return { + utilization: getUtilization(marketData.state), + lowerBound: rateToUtilization(apyToRate(apyRange.min), marketData.rateAtTarget), + upperBound: rateToUtilization(apyToRate(apyRange.max), marketData.rateAtTarget) + } +} + +const apyDeltaBips = (from: bigint, to: bigint, rateAtTarget: bigint): number => + Math.abs( + Number( + (rateToApy(utilizationToRate(to, rateAtTarget)) - + rateToApy(utilizationToRate(from, rateAtTarget))) / + 1_000_000_000n + ) / 1e5 + ) + /** * Keeps each market's borrow APY inside its configured range: converts the APY bounds to * utilization bounds via the AdaptiveCurveIRM inverse, deallocates from markets below range, @@ -38,57 +63,49 @@ const { min } = MathLib * 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). Allocations respect the market, adapter-level, and - * collateral-level caps. + * collateral-level caps; capacity freed by this plan's own deallocations (executed first) counts. */ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { return vaultData => { const vault = vaultData.vaultAddress const marketsData = vaultData.marketsData.filter(marketData => marketData.isAdaptiveCurve) - let totalAmountToDeallocate = 0n - let totalAmountToAllocate = 0n + // True only if at least one market that actually CONTRIBUTES assets moves enough — a capped-out + // or empty market must not arm the trigger for a plan whose real legs are all sub-threshold. + let didExceedMinApyDelta = false - let didExceedMinApyDelta = false // true if *at least one* market moves enough - - // Both passes iterate markets in the same order with the same clamps, so the totals gathered - // here (pool-clamped) equal what the leg pass can actually emit. + // Deallocations size first (the contract executes them first), crediting the freed capacity to + // the aggregate cap pools the allocation sizing then draws from. Both the sizing and leg passes + // walk markets in the same order with the same clamps, so the totals gathered here equal what + // the leg pass can actually emit. + let totalAmountToDeallocate = 0n const sizingPools = createDepositPools(vaultData, config.capBufferPercent) for (const marketData of marketsData) { - const apyRange = config.apyRange(vault, marketData.id) - const upperUtilizationBound = rateToUtilization( - apyToRate(apyRange.max), - marketData.rateAtTarget - ) - const lowerUtilizationBound = rateToUtilization( - apyToRate(apyRange.min), - marketData.rateAtTarget - ) - const utilization = getUtilization(marketData.state) - - if (utilization > upperUtilizationBound) { - totalAmountToAllocate += takeFromPools( - sizingPools, - marketData.params.collateralToken, - getDepositableAmount( - marketData, - vaultData.totalAssets, - upperUtilizationBound, - config.capBufferPercent - ) - ) - const apyDelta = - rateToApy(utilizationToRate(upperUtilizationBound, marketData.rateAtTarget)) - - rateToApy(utilizationToRate(utilization, marketData.rateAtTarget)) + const { utilization, lowerBound } = classifyMarket(config, vault, marketData) + if (utilization >= lowerBound) continue + const contribution = getWithdrawableAmount(marketData, lowerBound) + totalAmountToDeallocate += contribution + creditPools(sizingPools, marketData.params.collateralToken, contribution) + if (contribution > 0n) { didExceedMinApyDelta ||= - Math.abs(Number(apyDelta / 1_000_000_000n) / 1e5) > + apyDeltaBips(utilization, lowerBound, marketData.rateAtTarget) > config.minApyDeltaBips(vault, marketData.id) - } else if (utilization < lowerUtilizationBound) { - totalAmountToDeallocate += getWithdrawableAmount(marketData, lowerUtilizationBound) - const apyDelta = - rateToApy(utilizationToRate(lowerUtilizationBound, marketData.rateAtTarget)) - - rateToApy(utilizationToRate(utilization, marketData.rateAtTarget)) + } + } + + let totalAmountToAllocate = 0n + for (const marketData of marketsData) { + const { utilization, upperBound } = classifyMarket(config, vault, marketData) + if (utilization <= upperBound) continue + const contribution = takeFromPools( + sizingPools, + marketData.params.collateralToken, + getDepositableAmount(marketData, vaultData.totalAssets, upperBound, config.capBufferPercent) + ) + totalAmountToAllocate += contribution + if (contribution > 0n) { didExceedMinApyDelta ||= - Math.abs(Number(apyDelta / 1_000_000_000n) / 1e5) > + apyDeltaBips(utilization, upperBound, marketData.rateAtTarget) > config.minApyDeltaBips(vault, marketData.id) } } @@ -113,44 +130,46 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { const legPools = createDepositPools(vaultData, config.capBufferPercent) for (const marketData of marketsData) { - const apyRange = config.apyRange(vault, marketData.id) - const upperUtilizationBound = rateToUtilization( - apyToRate(apyRange.max), - marketData.rateAtTarget + if (remainingAmountToDeallocate === 0n) break + const { utilization, lowerBound } = classifyMarket(config, vault, marketData) + if (utilization >= lowerBound) continue + const toDeallocate = min( + getWithdrawableAmount(marketData, lowerBound), + remainingAmountToDeallocate ) - const lowerUtilizationBound = rateToUtilization( - apyToRate(apyRange.min), - marketData.rateAtTarget - ) - const utilization = getUtilization(marketData.state) - - if (utilization > upperUtilizationBound) { - const desired = min( - getDepositableAmount( - marketData, - vaultData.totalAssets, - upperUtilizationBound, - config.capBufferPercent - ), - remainingAmountToAllocate - ) - const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) - remainingAmountToAllocate -= toAllocate - if (toAllocate > 0n) { - allocations.push({ marketParams: marketData.params, assets: toAllocate }) - } - } else if (utilization < lowerUtilizationBound) { - const toDeallocate = min( - getWithdrawableAmount(marketData, lowerUtilizationBound), - remainingAmountToDeallocate - ) - remainingAmountToDeallocate -= toDeallocate - if (toDeallocate > 0n) { - deallocations.push({ marketParams: marketData.params, assets: toDeallocate }) - } + remainingAmountToDeallocate -= toDeallocate + creditPools(legPools, marketData.params.collateralToken, toDeallocate) + if (toDeallocate > 0n) { + deallocations.push({ + marketId: marketData.id, + marketParams: marketData.params, + assets: toDeallocate + }) } + } - if (remainingAmountToDeallocate === 0n && remainingAmountToAllocate === 0n) break + for (const marketData of marketsData) { + if (remainingAmountToAllocate === 0n) break + const { utilization, upperBound } = classifyMarket(config, vault, marketData) + if (utilization <= upperBound) continue + const desired = min( + getDepositableAmount( + marketData, + vaultData.totalAssets, + upperBound, + config.capBufferPercent + ), + remainingAmountToAllocate + ) + const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) + remainingAmountToAllocate -= toAllocate + if (toAllocate > 0n) { + allocations.push({ + marketId: marketData.id, + marketParams: marketData.params, + assets: toAllocate + }) + } } return { allocations, deallocations } satisfies Reallocation diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts index 63b00424..c4f4db4d 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -1,12 +1,13 @@ import type { Address } from 'viem' import { MathLib } from '@morpho-org/blue-sdk' -import { zeroAddress } from 'viem' +import { isAddressEqual, zeroAddress } from 'viem' import type { Reallocation, ReallocationAction, Strategy } from './strategy' import { createDepositPools, + creditPools, getDepositableAmount, getUtilization, getWithdrawableAmount, @@ -24,17 +25,19 @@ const { min, WAD, wDivDown } = MathLib /** * 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. Legs need not - * balance — the difference flows through the vault's idle balance. Fires only when at least one - * market's deviation exceeds the vault's min-delta threshold. Allocations respect the market, - * adapter-level, and collateral-level caps. + * supply): deallocates from markets below it, allocates into markets above it. Allocations may + * exceed deallocations by at most the vault's idle balance (`allocate` pulls from vault balance, + * so anything beyond that reverts); excess deallocations park in idle. Fires only when at least + * one market's deviation exceeds the vault's min-delta threshold. Allocations respect the market, + * adapter-level, and collateral-level caps; capacity freed by this plan's own deallocations + * (executed first) counts. */ export const createEqualizeUtilizationsStrategy = ( config: EqualizeUtilizationsConfig ): Strategy => { return vaultData => { const marketsData = vaultData.marketsData.filter( - marketData => marketData.params.collateralToken !== zeroAddress + marketData => !isAddressEqual(marketData.params.collateralToken, zeroAddress) ) const totalSupply = marketsData.reduce( @@ -49,35 +52,57 @@ export const createEqualizeUtilizationsStrategy = ( // target asks for more than the markets hold, so every resulting plan reverts. const targetUtilization = min(wDivDown(totalBorrow, totalSupply), WAD) - let totalAmountToDeallocate = 0n - let totalAmountToAllocate = 0n - - let didExceedMinUtilizationDelta = false // true if *at least one* market moves enough + // True only if at least one market that actually CONTRIBUTES assets deviates enough — a + // capped-out or empty market must not arm the trigger for a plan whose real legs are all + // sub-threshold. + let didExceedMinUtilizationDelta = false const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) + const deviationBips = (utilization: bigint): number => + Math.abs(Number((utilization - targetUtilization) / 1_000_000_000n) / 1e5) - // Both passes iterate markets in the same order with the same clamps, so the totals gathered - // here (pool-clamped) equal what the leg pass can actually emit. + // Deallocations size first (the contract executes them first), crediting the freed capacity to + // the aggregate cap pools the allocation sizing then draws from. Both the sizing and leg passes + // walk markets in the same order with the same clamps, so the totals gathered here equal what + // the leg pass can actually emit. + let totalAmountToDeallocate = 0n const sizingPools = createDepositPools(vaultData, config.capBufferPercent) for (const marketData of marketsData) { const utilization = getUtilization(marketData.state) - if (utilization > targetUtilization) { - totalAmountToAllocate += takeFromPools( - sizingPools, - marketData.params.collateralToken, - getDepositableAmount( - marketData, - vaultData.totalAssets, - targetUtilization, - config.capBufferPercent - ) + if (utilization > targetUtilization) continue + const contribution = getWithdrawableAmount(marketData, targetUtilization) + totalAmountToDeallocate += contribution + creditPools(sizingPools, marketData.params.collateralToken, contribution) + if (contribution > 0n) { + didExceedMinUtilizationDelta ||= deviationBips(utilization) > minUtilizationDeltaBips + } + } + + let totalAmountToAllocate = 0n + for (const marketData of marketsData) { + const utilization = getUtilization(marketData.state) + if (utilization <= targetUtilization) continue + const contribution = takeFromPools( + sizingPools, + marketData.params.collateralToken, + getDepositableAmount( + marketData, + vaultData.totalAssets, + targetUtilization, + config.capBufferPercent ) - } else { - totalAmountToDeallocate += getWithdrawableAmount(marketData, targetUtilization) + ) + totalAmountToAllocate += contribution + if (contribution > 0n) { + didExceedMinUtilizationDelta ||= deviationBips(utilization) > minUtilizationDeltaBips } + } - didExceedMinUtilizationDelta ||= - Math.abs(Number((utilization - targetUtilization) / 1_000_000_000n) / 1e5) > - minUtilizationDeltaBips + // `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. + if (totalAmountToAllocate > totalAmountToDeallocate) { + totalAmountToAllocate = + totalAmountToDeallocate + + min(totalAmountToAllocate - totalAmountToDeallocate, vaultData.idleAssets) } if ( @@ -95,35 +120,46 @@ export const createEqualizeUtilizationsStrategy = ( const legPools = createDepositPools(vaultData, config.capBufferPercent) for (const marketData of marketsData) { + if (remainingAmountToDeallocate === 0n) break const utilization = getUtilization(marketData.state) - - if (utilization > targetUtilization) { - const desired = min( - getDepositableAmount( - marketData, - vaultData.totalAssets, - targetUtilization, - config.capBufferPercent - ), - remainingAmountToAllocate - ) - const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) - remainingAmountToAllocate -= toAllocate - if (toAllocate > 0n) { - allocations.push({ marketParams: marketData.params, assets: toAllocate }) - } - } else { - const toDeallocate = min( - getWithdrawableAmount(marketData, targetUtilization), - remainingAmountToDeallocate - ) - remainingAmountToDeallocate -= toDeallocate - if (toDeallocate > 0n) { - deallocations.push({ marketParams: marketData.params, assets: toDeallocate }) - } + if (utilization > targetUtilization) continue + const toDeallocate = min( + getWithdrawableAmount(marketData, targetUtilization), + remainingAmountToDeallocate + ) + remainingAmountToDeallocate -= toDeallocate + creditPools(legPools, marketData.params.collateralToken, toDeallocate) + if (toDeallocate > 0n) { + deallocations.push({ + marketId: marketData.id, + marketParams: marketData.params, + assets: toDeallocate + }) } + } - if (remainingAmountToDeallocate === 0n && remainingAmountToAllocate === 0n) break + for (const marketData of marketsData) { + if (remainingAmountToAllocate === 0n) break + const utilization = getUtilization(marketData.state) + if (utilization <= targetUtilization) continue + const desired = min( + getDepositableAmount( + marketData, + vaultData.totalAssets, + targetUtilization, + config.capBufferPercent + ), + remainingAmountToAllocate + ) + const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) + remainingAmountToAllocate -= toAllocate + if (toAllocate > 0n) { + allocations.push({ + marketId: marketData.id, + marketParams: marketData.params, + assets: toAllocate + }) + } } return { allocations, deallocations } satisfies Reallocation diff --git a/bots/vault-v2-reallocation/src/strategies/strategy.ts b/bots/vault-v2-reallocation/src/strategies/strategy.ts index 86b28bdc..4b666e14 100644 --- a/bots/vault-v2-reallocation/src/strategies/strategy.ts +++ b/bots/vault-v2-reallocation/src/strategies/strategy.ts @@ -1,9 +1,12 @@ 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 } diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts index 03c474bd..f949e1dd 100644 --- a/bots/vault-v2-reallocation/src/vault-data.ts +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -10,7 +10,7 @@ import { } from '@morpho-org/blue-sdk' import { fetchAccrualVaultV2, vaultV2Abi } from '@morpho-org/blue-sdk-viem' import { getAddress, isAddressEqual } from 'viem' -import { getBlock, readContract } from 'viem/actions' +import { getBlock, multicall } from 'viem/actions' import { InvalidVaultError } from './invalid-vault.error' @@ -119,36 +119,33 @@ const normalizeAdapterMarkets = (adapter: MarketAdapter, timestamp: bigint): Ada }) } -const readCap = async ( +const CAP_FUNCTIONS = ['absoluteCap', 'relativeCap', 'allocation'] as const + +// One multicall for all ids' cap state — 3 × (markets + collaterals + 1) reads would otherwise be +// individual eth_calls per tick. +const readCaps = async ( client: Client, vault: Address, - id: Hex, + ids: readonly Hex[], blockNumber: bigint -): Promise => { - const [absolute, relative, allocation] = await Promise.all([ - readContract(client, { - address: vault, - abi: vaultV2Abi, - functionName: 'absoluteCap', - args: [id], - blockNumber - }), - readContract(client, { - address: vault, - abi: vaultV2Abi, - functionName: 'relativeCap', - args: [id], - blockNumber - }), - readContract(client, { - address: vault, - abi: vaultV2Abi, - functionName: 'allocation', - args: [id], - blockNumber - }) - ]) - return { absolute, relative, allocation } +): Promise => { + const results = await multicall(client, { + allowFailure: false, + blockNumber, + contracts: ids.flatMap(id => + CAP_FUNCTIONS.map(functionName => ({ + address: vault, + abi: vaultV2Abi, + functionName, + args: [id] as const + })) + ) + }) + return ids.map((_, i) => ({ + absolute: results[i * CAP_FUNCTIONS.length] as bigint, + relative: results[i * CAP_FUNCTIONS.length + 1] as bigint, + allocation: results[i * CAP_FUNCTIONS.length + 2] as bigint + })) } /** @@ -165,7 +162,12 @@ export const fetchVaultV2Data = async ( vault: Address, { chainId, blockNumber }: { chainId: number; blockNumber: bigint } ): Promise => { - const vaultV2 = await fetchAccrualVaultV2(vault, client, { chainId, blockNumber }) + // Accrue to the pinned block's timestamp, not wall clock, so the snapshot is coherent and + // reproducible against the pinned reads. + const [{ timestamp }, vaultV2] = await Promise.all([ + getBlock(client, { blockNumber }), + fetchAccrualVaultV2(vault, client, { chainId, blockNumber }) + ]) const marketAdapters = vaultV2.accrualAdapters.filter( (adapter): adapter is MarketAdapter => @@ -181,58 +183,54 @@ export const fetchVaultV2Data = async ( const adapter = marketAdapters[0]! const adapterAddress = getAddress(adapter.address) - // Accrue to the pinned block's timestamp, not wall clock, so the snapshot is coherent and - // reproducible against the pinned reads. - const { timestamp } = await getBlock(client, { blockNumber }) const adapterMarkets = normalizeAdapterMarkets(adapter, timestamp) const collateralTokens = [ ...new Set(adapterMarkets.map(({ params }) => getAddress(params.collateralToken))) ] - const [adapterCap, collateralCapList, marketsData] = await Promise.all([ - // Both adapter generations derive identical "this"/"collateralToken"/"this/marketParams" ids. - readCap(client, vault, VaultV2MorphoMarketV1Adapter.adapterId(adapterAddress), blockNumber), - Promise.all( - collateralTokens.map(async token => ({ - token, - cap: await readCap( - client, - vault, - VaultV2MorphoMarketV1Adapter.collateralId(token), - blockNumber - ) - })) - ), - Promise.all( - adapterMarkets.map( - async ({ params, state, vaultAssets, rateAtTarget }): Promise => { - const capId = VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, params) - const cap = await readCap(client, vault, capId, blockNumber) - return { - id: params.id, - capId, - params: { - loanToken: params.loanToken, - collateralToken: params.collateralToken, - oracle: params.oracle, - irm: params.irm, - lltv: params.lltv - }, - state: { - totalSupplyAssets: state.totalSupplyAssets, - totalSupplyShares: state.totalSupplyShares, - totalBorrowAssets: state.totalBorrowAssets, - totalBorrowShares: state.totalBorrowShares - }, - cap, - vaultAssets, - rateAtTarget, - isAdaptiveCurve: isAdaptiveCurveMarket(params.irm, rateAtTarget, chainId) - } - } - ) - ) - ]) + // Both adapter generations derive identical "this"/"collateralToken"/"this/marketParams" ids. + const marketCapIds = adapterMarkets.map(({ params }) => + VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, params) + ) + const caps = await readCaps( + client, + vault, + [ + VaultV2MorphoMarketV1Adapter.adapterId(adapterAddress), + ...collateralTokens.map(token => VaultV2MorphoMarketV1Adapter.collateralId(token)), + ...marketCapIds + ], + blockNumber + ) + const adapterCap = caps[0]! + const collateralCaps = Object.fromEntries( + collateralTokens.map((token, i) => [token, caps[1 + i]!]) + ) + const marketCaps = caps.slice(1 + collateralTokens.length) + + const marketsData = adapterMarkets.map( + ({ params, state, vaultAssets, rateAtTarget }, i): VaultV2MarketData => ({ + id: params.id, + capId: marketCapIds[i]!, + params: { + loanToken: params.loanToken, + collateralToken: params.collateralToken, + oracle: params.oracle, + irm: params.irm, + lltv: params.lltv + }, + state: { + totalSupplyAssets: state.totalSupplyAssets, + totalSupplyShares: state.totalSupplyShares, + totalBorrowAssets: state.totalBorrowAssets, + totalBorrowShares: state.totalBorrowShares + }, + cap: marketCaps[i]!, + vaultAssets, + rateAtTarget, + isAdaptiveCurve: isAdaptiveCurveMarket(params.irm, rateAtTarget, chainId) + }) + ) return { vaultAddress: vault, @@ -240,7 +238,7 @@ export const fetchVaultV2Data = async ( totalAssets: vaultV2.accrueInterest(timestamp).vault._totalAssets, idleAssets: vaultV2.assetBalance, adapterCap, - collateralCaps: Object.fromEntries(collateralCapList.map(({ token, cap }) => [token, cap])), + collateralCaps, marketsData } } diff --git a/bots/vault-v2-reallocation/test/encode.test.ts b/bots/vault-v2-reallocation/test/encode.test.ts index 84daf57b..39f6ca7d 100644 --- a/bots/vault-v2-reallocation/test/encode.test.ts +++ b/bots/vault-v2-reallocation/test/encode.test.ts @@ -15,8 +15,8 @@ describe('encodeReallocation', () => { const hotParams = makeMarketParams() const coldParams = makeMarketParams() const data = encodeReallocation(ADAPTER, { - allocations: [{ marketParams: hotParams, assets: parseUnits('123', 6) }], - deallocations: [{ marketParams: coldParams, assets: parseUnits('456', 6) }] + allocations: [{ marketId: '0x01', marketParams: hotParams, assets: parseUnits('123', 6) }], + deallocations: [{ marketId: '0x02', marketParams: coldParams, assets: parseUnits('456', 6) }] }) const outer = decodeFunctionData({ abi: vaultV2Abi, data }) @@ -45,7 +45,7 @@ describe('encodeReallocation', () => { const params = makeMarketParams() const data = encodeReallocation(ADAPTER, { allocations: [], - deallocations: [{ marketParams: params, assets: 1n }] + deallocations: [{ marketId: '0x01', marketParams: params, assets: 1n }] }) const outer = decodeFunctionData({ abi: vaultV2Abi, data }) if (outer.functionName !== 'multicall') throw new Error('expected multicall') diff --git a/bots/vault-v2-reallocation/test/math.test.ts b/bots/vault-v2-reallocation/test/math.test.ts index 2298c825..9dee054e 100644 --- a/bots/vault-v2-reallocation/test/math.test.ts +++ b/bots/vault-v2-reallocation/test/math.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { createDepositPools, + creditPools, getCapHeadroom, getDepositableAmount, getUtilization, @@ -17,34 +18,41 @@ const WAD = 10n ** 18n describe('getCapHeadroom', () => { const totalAssets = parseUnits('100000', 6) - it('measures headroom against the on-chain allocation, not accrued assets', () => { - // allocation lags accrued position assets; headroom must use allocation. + it('measures headroom from the caller-provided basis', () => { const cap = { absolute: parseUnits('11000', 6), relative: WAD, allocation: parseUnits('10000', 6) } - const headroom = getCapHeadroom(cap, totalAssets, 100) - expect(headroom).toBe(parseUnits('1000', 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, 100)).toBe(parseUnits('500', 6)) }) - it('applies the buffer to both cap legs and floors at zero', () => { + 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, 100)).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 allocation → zero headroom. - expect(getCapHeadroom(cap, totalAssets, 99.99)).toBe(0n) + // Buffered absolute (99.99%) is below the basis → zero headroom. + expect(getCapHeadroom(cap, parseUnits('10000', 6), totalAssets, 99.99)).toBe(0n) }) - it('binds on the relative cap when it is the smaller ceiling', () => { + 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, totalAssets, 100)).toBe(parseUnits('1000', 6)) + expect(getCapHeadroom(cap, parseUnits('9000', 6), totalAssets, 100)).toBe(parseUnits('1000', 6)) }) }) @@ -65,6 +73,20 @@ describe('getDepositableAmount / getWithdrawableAmount', () => { 100 ) 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, 100)).toBe( + parseUnits('100', 6) + ) + const driftedAboveCapBase = { ...market, vaultAssets: vaultAssets + parseUnits('40', 6) } + expect( + getDepositableAmount(driftedAboveCapBase, parseUnits('100000', 6), (45n * WAD) / 100n, 100) + ).toBe(parseUnits('60', 6)) }) it('bounds withdrawals by the adapter position', () => { @@ -76,6 +98,21 @@ describe('getDepositableAmount / getWithdrawableAmount', () => { 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, 100)).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({ @@ -119,6 +156,51 @@ describe('deposit pools', () => { expect(pools.adapter).toBe(parseUnits('100', 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, 100) + // Adapter basis = stored 10000 + drift 100 → headroom 200 instead of 300. + expect(pools.adapter).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, 100) + expect(pools.adapter).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, diff --git a/bots/vault-v2-reallocation/test/runner/tick.test.ts b/bots/vault-v2-reallocation/test/runner/tick.test.ts index edda59fc..a99abf2e 100644 --- a/bots/vault-v2-reallocation/test/runner/tick.test.ts +++ b/bots/vault-v2-reallocation/test/runner/tick.test.ts @@ -32,8 +32,10 @@ const someVaultData = (): VaultV2Data => makeVaultData([makeMarket({ utilization: 0n, vaultAssets: 0n, rateAtTarget: RATE_AT_TARGET })]) const someReallocation = (): Reallocation => ({ - allocations: [{ marketParams: makeMarketParams(), assets: parseUnits('1', 6) }], - deallocations: [{ marketParams: makeMarketParams(), assets: parseUnits('1', 6) }] + allocations: [{ marketId: '0x01', marketParams: makeMarketParams(), assets: parseUnits('1', 6) }], + deallocations: [ + { marketId: '0x02', marketParams: makeMarketParams(), assets: parseUnits('1', 6) } + ] }) const makeDeps = (overrides: Partial = {}) => { @@ -42,6 +44,7 @@ const makeDeps = (overrides: Partial = {}) => { vaults: [VAULT_A], chainHead: 100n, isAllocator: vi.fn(async () => true), + expectedAdapter: vi.fn(() => undefined), fetchVault: vi.fn(async () => someVaultData()), strategy: vi.fn(() => undefined), encodeReallocation: vi.fn(() => DATA), @@ -114,6 +117,18 @@ describe('runTick', () => { 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 fetch/strategy/simulate while the allocator role is missing', async () => { const { deps, events } = makeDeps({ isAllocator: vi.fn(async () => false) }) await runTick(deps) diff --git a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts index 777bd44c..5cabb7a0 100644 --- a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -194,6 +194,25 @@ describe('createApyRangeStrategy', () => { }) }) + 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() + }) + }) + describe('foreign-IRM exclusion', () => { it('excludes non-AdaptiveCurve markets from both legs', () => { const strategy = makeStrategy() @@ -282,7 +301,9 @@ describe('createApyRangeStrategy', () => { ) expect(result).toBeDefined() const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) - expect(totalAllocated).toBeLessThanOrEqual(parseUnits('50', 6)) + 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 index 395dfe50..4aeb63f7 100644 --- a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -2,7 +2,14 @@ import { getAddress, parseUnits } from 'viem' import { beforeEach, describe, expect, it } from 'vitest' import { createEqualizeUtilizationsStrategy } from '../../src/strategies/equalize-utilizations' -import { makeMarket, makeVaultData, RATE_AT_TARGET, resetMarketCounter, VAULT } from './helpers' +import { + makeMarket, + makeMarketParams, + makeVaultData, + RATE_AT_TARGET, + resetMarketCounter, + VAULT +} from './helpers' const makeStrategy = (minUtilizationDeltaBips: (vault: `0x${string}`) => number = () => 0) => createEqualizeUtilizationsStrategy({ capBufferPercent: 99.99, minUtilizationDeltaBips }) @@ -79,6 +86,46 @@ describe('createEqualizeUtilizationsStrategy', () => { } }) + 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({ @@ -126,15 +173,19 @@ describe('createEqualizeUtilizationsStrategy', () => { expect(seen).not.toContain(VAULT) }) - it('clamps allocations to the market cap headroom measured against allocation(id)', () => { + 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, - // The enforced allocation already sits 100 under the absolute cap even though position - // assets accrued past it — headroom must come from allocation, not vaultAssets. - cap: { absolute: vaultAssets + parseUnits('100', 6), relative: WAD, allocation: 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({ @@ -149,6 +200,87 @@ describe('createEqualizeUtilizationsStrategy', () => { 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 })) + if (result) { + 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({ @@ -175,7 +307,9 @@ describe('createEqualizeUtilizationsStrategy', () => { expect(result).toBeDefined() const totalAllocated = result!.allocations.reduce((acc, leg) => acc + leg.assets, 0n) - expect(totalAllocated).toBeLessThanOrEqual(parseUnits('50', 6)) + 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) }) @@ -186,9 +320,11 @@ describe('createEqualizeUtilizationsStrategy', () => { 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]) diff --git a/bots/vault-v2-reallocation/test/strategies/helpers.ts b/bots/vault-v2-reallocation/test/strategies/helpers.ts index 44e02d63..98c3f182 100644 --- a/bots/vault-v2-reallocation/test/strategies/helpers.ts +++ b/bots/vault-v2-reallocation/test/strategies/helpers.ts @@ -50,8 +50,9 @@ export const makeMarket = (opts: { rateAtTarget: bigint params?: InputMarketParams isAdaptiveCurve?: boolean + supplyAssets?: bigint }): VaultV2MarketData => { - const totalSupplyAssets = parseUnits('100000', 6) + const totalSupplyAssets = opts.supplyAssets ?? parseUnits('100000', 6) const totalBorrowAssets = MathLib.wMulDown(totalSupplyAssets, opts.utilization) return { id: makeMarketId(), diff --git a/packages/bot-kit/src/policy.ts b/packages/bot-kit/src/policy.ts index af012188..a189e216 100644 --- a/packages/bot-kit/src/policy.ts +++ b/packages/bot-kit/src/policy.ts @@ -1,5 +1,6 @@ import type { Address, Hex } from 'viem' +import { tryCatch } from '@repo/utils' import { decodeAbiParameters, isAddress, isAddressEqual } from 'viem' /** The only Executor entrypoint the signer authorizes: exec_606BaXt(bytes[]). */ @@ -114,7 +115,9 @@ export function evaluatePolicy(policy: Policy, tx: PolicyTx): PolicyDecision { return deny('selector', `calldata must call configured selector ${selector}`) } if (policy.multicall) { - const reason = checkMulticall(policy.multicall, tx) + // A throw out of the deep check must never escape the PolicyDecision contract — default-deny. + const { data: reason, error } = tryCatch(() => checkMulticall(policy.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 bb4815e7..66aa8917 100644 --- a/packages/bot-kit/test/policy.test.ts +++ b/packages/bot-kit/test/policy.test.ts @@ -185,6 +185,18 @@ describe('evaluatePolicy multicall envelope', () => { ).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 }) }) From 3fb572ca4452d6df284ddbdbb75727299c9ef0dd Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 12:26:57 -0500 Subject: [PATCH 07/14] refactor(vault-v2-reallocation): mirror v1 mechanical simplification pass capBufferWad converted once at strategy construction, dead MarketState share fields dropped, reallocation.dry_run payload slimmed to the vault (the plan is already in reallocation.found). Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/src/math.ts | 20 +++++------ bots/vault-v2-reallocation/src/runner/tick.ts | 2 +- .../src/strategies/apy-range.ts | 15 +++------ .../src/strategies/equalize-utilizations.ts | 10 +++--- .../src/strategies/index.ts | 4 +-- bots/vault-v2-reallocation/src/vault-data.ts | 6 +--- bots/vault-v2-reallocation/test/math.test.ts | 33 ++++++++----------- .../test/strategies/apy-range.test.ts | 2 +- .../strategies/equalize-utilizations.test.ts | 3 +- .../test/strategies/helpers.ts | 7 +--- 10 files changed, 39 insertions(+), 63 deletions(-) diff --git a/bots/vault-v2-reallocation/src/math.ts b/bots/vault-v2-reallocation/src/math.ts index 97ad2705..93c7a31e 100644 --- a/bots/vault-v2-reallocation/src/math.ts +++ b/bots/vault-v2-reallocation/src/math.ts @@ -61,13 +61,12 @@ export const getCapHeadroom = ( cap: CapState, basis: bigint, totalAssets: bigint, - capBufferPercent: number + capBufferWad: bigint ): bigint => { - const buffer = percentToWad(capBufferPercent) - const bufferedAbsolute = MathLib.wMulDown(cap.absolute, buffer) + const bufferedAbsolute = MathLib.wMulDown(cap.absolute, capBufferWad) const absoluteHeadroom = bufferedAbsolute > basis ? bufferedAbsolute - basis : 0n if (cap.relative === MathLib.WAD) return absoluteHeadroom - const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), buffer) + const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), capBufferWad) const relativeHeadroom = bufferedRelative > basis ? bufferedRelative - basis : 0n return MathLib.min(absoluteHeadroom, relativeHeadroom) } @@ -88,7 +87,7 @@ export const getDepositableAmount = ( marketData: VaultV2MarketData, totalAssets: bigint, targetUtilization: bigint, - capBufferPercent: number + capBufferWad: bigint ): bigint => MathLib.min( getDepositToUtilization(marketData.state, targetUtilization), @@ -96,7 +95,7 @@ export const getDepositableAmount = ( marketData.cap, MathLib.max(marketData.cap.allocation, marketData.vaultAssets), totalAssets, - capBufferPercent + capBufferWad ) ) @@ -114,10 +113,7 @@ type DepositPools = { byCollateral: Map } -export const createDepositPools = ( - vaultData: VaultV2Data, - capBufferPercent: number -): DepositPools => { +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) { @@ -129,7 +125,7 @@ export const createDepositPools = ( vaultData.adapterCap, vaultData.adapterCap.allocation + totalDrift, vaultData.totalAssets, - capBufferPercent + capBufferWad ), byCollateral: new Map( Object.entries(vaultData.collateralCaps).map(([token, cap]) => [ @@ -138,7 +134,7 @@ export const createDepositPools = ( cap, cap.allocation + (driftByCollateral.get(getAddress(token)) ?? 0n), vaultData.totalAssets, - capBufferPercent + capBufferWad ) ]) ) diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts index 2e75b83e..d508481f 100644 --- a/bots/vault-v2-reallocation/src/runner/tick.ts +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -111,7 +111,7 @@ const processVault = async ( if (deps.dryRun) { counters.dry_runs++ - deps.logger.info('reallocation.dry_run', { vault, legs, allocations: summary }) + deps.logger.info('reallocation.dry_run', { vault }) return } diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index 392a9e49..64b9b6e8 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -21,7 +21,7 @@ import { export type ApyRangeConfig = { /** Whether excess deallocations may be parked in the vault's idle balance. */ allowIdleReallocation: boolean - capBufferPercent: number + 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). */ @@ -79,7 +79,7 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { // walk markets in the same order with the same clamps, so the totals gathered here equal what // the leg pass can actually emit. let totalAmountToDeallocate = 0n - const sizingPools = createDepositPools(vaultData, config.capBufferPercent) + const sizingPools = createDepositPools(vaultData, config.capBufferWad) for (const marketData of marketsData) { const { utilization, lowerBound } = classifyMarket(config, vault, marketData) if (utilization >= lowerBound) continue @@ -100,7 +100,7 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { const contribution = takeFromPools( sizingPools, marketData.params.collateralToken, - getDepositableAmount(marketData, vaultData.totalAssets, upperBound, config.capBufferPercent) + getDepositableAmount(marketData, vaultData.totalAssets, upperBound, config.capBufferWad) ) totalAmountToAllocate += contribution if (contribution > 0n) { @@ -128,7 +128,7 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { const allocations: ReallocationAction[] = [] const deallocations: ReallocationAction[] = [] - const legPools = createDepositPools(vaultData, config.capBufferPercent) + const legPools = createDepositPools(vaultData, config.capBufferWad) for (const marketData of marketsData) { if (remainingAmountToDeallocate === 0n) break const { utilization, lowerBound } = classifyMarket(config, vault, marketData) @@ -153,12 +153,7 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { const { utilization, upperBound } = classifyMarket(config, vault, marketData) if (utilization <= upperBound) continue const desired = min( - getDepositableAmount( - marketData, - vaultData.totalAssets, - upperBound, - config.capBufferPercent - ), + getDepositableAmount(marketData, vaultData.totalAssets, upperBound, config.capBufferWad), remainingAmountToAllocate ) const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts index c4f4db4d..662b4836 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -15,7 +15,7 @@ import { } from '../math' type EqualizeUtilizationsConfig = { - capBufferPercent: number + capBufferWad: bigint /** Firing threshold: at least one market's utilization must deviate from target by this (bips). */ minUtilizationDeltaBips: (vault: Address) => number } @@ -65,7 +65,7 @@ export const createEqualizeUtilizationsStrategy = ( // walk markets in the same order with the same clamps, so the totals gathered here equal what // the leg pass can actually emit. let totalAmountToDeallocate = 0n - const sizingPools = createDepositPools(vaultData, config.capBufferPercent) + const sizingPools = createDepositPools(vaultData, config.capBufferWad) for (const marketData of marketsData) { const utilization = getUtilization(marketData.state) if (utilization > targetUtilization) continue @@ -88,7 +88,7 @@ export const createEqualizeUtilizationsStrategy = ( marketData, vaultData.totalAssets, targetUtilization, - config.capBufferPercent + config.capBufferWad ) ) totalAmountToAllocate += contribution @@ -118,7 +118,7 @@ export const createEqualizeUtilizationsStrategy = ( const allocations: ReallocationAction[] = [] const deallocations: ReallocationAction[] = [] - const legPools = createDepositPools(vaultData, config.capBufferPercent) + const legPools = createDepositPools(vaultData, config.capBufferWad) for (const marketData of marketsData) { if (remainingAmountToDeallocate === 0n) break const utilization = getUtilization(marketData.state) @@ -147,7 +147,7 @@ export const createEqualizeUtilizationsStrategy = ( marketData, vaultData.totalAssets, targetUtilization, - config.capBufferPercent + config.capBufferWad ), remainingAmountToAllocate ) diff --git a/bots/vault-v2-reallocation/src/strategies/index.ts b/bots/vault-v2-reallocation/src/strategies/index.ts index 3c674d3a..f586f032 100644 --- a/bots/vault-v2-reallocation/src/strategies/index.ts +++ b/bots/vault-v2-reallocation/src/strategies/index.ts @@ -24,7 +24,7 @@ export const createStrategy = (config: Config): Strategy => { case 'apy-range': return createApyRangeStrategy({ allowIdleReallocation: config.allowIdleReallocation, - capBufferPercent: CAP_BUFFER_PERCENT, + capBufferWad: percentToWad(CAP_BUFFER_PERCENT), apyRange: (vault, marketId) => { const range = resolveApyRange(config.chainId, vault, marketId) return { min: percentToWad(range.min), max: percentToWad(range.max) } @@ -34,7 +34,7 @@ export const createStrategy = (config: Config): Strategy => { }) case 'equalize-utilizations': return createEqualizeUtilizationsStrategy({ - capBufferPercent: CAP_BUFFER_PERCENT, + capBufferWad: percentToWad(CAP_BUFFER_PERCENT), minUtilizationDeltaBips: vault => resolveMinUtilizationDeltaBips(config.chainId, vault, config.minUtilizationDeltaBips) }) diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts index f949e1dd..3abe78c7 100644 --- a/bots/vault-v2-reallocation/src/vault-data.ts +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -16,9 +16,7 @@ import { InvalidVaultError } from './invalid-vault.error' export type MarketState = { totalSupplyAssets: bigint - totalSupplyShares: bigint totalBorrowAssets: bigint - totalBorrowShares: bigint } /** @@ -221,9 +219,7 @@ export const fetchVaultV2Data = async ( }, state: { totalSupplyAssets: state.totalSupplyAssets, - totalSupplyShares: state.totalSupplyShares, - totalBorrowAssets: state.totalBorrowAssets, - totalBorrowShares: state.totalBorrowShares + totalBorrowAssets: state.totalBorrowAssets }, cap: marketCaps[i]!, vaultAssets, diff --git a/bots/vault-v2-reallocation/test/math.test.ts b/bots/vault-v2-reallocation/test/math.test.ts index 9dee054e..0a095b9c 100644 --- a/bots/vault-v2-reallocation/test/math.test.ts +++ b/bots/vault-v2-reallocation/test/math.test.ts @@ -26,14 +26,14 @@ describe('getCapHeadroom', () => { } // 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, 100)).toBe(parseUnits('500', 6)) + 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, 100)).toBe(parseUnits('900000', 6)) + expect(getCapHeadroom(cap, totalAssets, totalAssets, WAD)).toBe(parseUnits('900000', 6)) }) it('applies the buffer and floors at zero', () => { @@ -43,7 +43,7 @@ describe('getCapHeadroom', () => { allocation: parseUnits('10000', 6) } // Buffered absolute (99.99%) is below the basis → zero headroom. - expect(getCapHeadroom(cap, parseUnits('10000', 6), totalAssets, 99.99)).toBe(0n) + expect(getCapHeadroom(cap, parseUnits('10000', 6), totalAssets, percentToWad(99.99))).toBe(0n) }) it('binds on a sub-WAD relative cap when it is the smaller ceiling', () => { @@ -52,7 +52,7 @@ describe('getCapHeadroom', () => { relative: parseUnits('0.1', 18), // 10% of totalAssets = 10k allocation: parseUnits('9000', 6) } - expect(getCapHeadroom(cap, parseUnits('9000', 6), totalAssets, 100)).toBe(parseUnits('1000', 6)) + expect(getCapHeadroom(cap, parseUnits('9000', 6), totalAssets, WAD)).toBe(parseUnits('1000', 6)) }) }) @@ -70,7 +70,7 @@ describe('getDepositableAmount / getWithdrawableAmount', () => { market, parseUnits('100000', 6), (45n * WAD) / 100n, - 100 + WAD ) expect(depositable).toBe(parseUnits('100', 6)) @@ -80,12 +80,12 @@ describe('getDepositableAmount / getWithdrawableAmount', () => { ...market, cap: { ...market.cap, allocation: vaultAssets - parseUnits('40', 6) } } - expect(getDepositableAmount(drifted, parseUnits('100000', 6), (45n * WAD) / 100n, 100)).toBe( + 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, 100) + getDepositableAmount(driftedAboveCapBase, parseUnits('100000', 6), (45n * WAD) / 100n, WAD) ).toBe(parseUnits('60', 6)) }) @@ -104,7 +104,7 @@ describe('getDepositableAmount / getWithdrawableAmount', () => { vaultAssets: parseUnits('10000', 6), rateAtTarget: RATE_AT_TARGET }) - expect(getDepositableAmount(market, parseUnits('100000', 6), 0n, 100)).toBe(0n) + expect(getDepositableAmount(market, parseUnits('100000', 6), 0n, WAD)).toBe(0n) const emptyMarket = makeMarket({ utilization: 0n, vaultAssets: parseUnits('10000', 6), @@ -114,14 +114,7 @@ describe('getDepositableAmount / getWithdrawableAmount', () => { }) it('returns 0 utilization for an empty market instead of dividing by zero', () => { - expect( - getUtilization({ - totalSupplyAssets: 0n, - totalSupplyShares: 0n, - totalBorrowAssets: 0n, - totalBorrowShares: 0n - }) - ).toBe(0n) + expect(getUtilization({ totalSupplyAssets: 0n, totalBorrowAssets: 0n })).toBe(0n) }) }) @@ -148,7 +141,7 @@ describe('deposit pools', () => { } } }) - const pools = createDepositPools(vaultData, 100) + 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. @@ -176,7 +169,7 @@ describe('deposit pools', () => { allocation: parseUnits('10000', 6) } }) - const pools = createDepositPools(vaultData, 100) + const pools = createDepositPools(vaultData, WAD) // Adapter basis = stored 10000 + drift 100 → headroom 200 instead of 300. expect(pools.adapter).toBe(parseUnits('200', 6)) }) @@ -195,7 +188,7 @@ describe('deposit pools', () => { allocation: parseUnits('10000', 6) } }) - const pools = createDepositPools(vaultData, 100) + const pools = createDepositPools(vaultData, WAD) expect(pools.adapter).toBe(0n) creditPools(pools, collateral, parseUnits('700', 6)) expect(takeFromPools(pools, collateral, parseUnits('1000', 6))).toBe(parseUnits('700', 6)) @@ -207,7 +200,7 @@ describe('deposit pools', () => { vaultAssets: parseUnits('10000', 6), rateAtTarget: RATE_AT_TARGET }) - const pools = createDepositPools(makeVaultData([market]), 100) + 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/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts index 5cabb7a0..8c0b0e52 100644 --- a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -22,7 +22,7 @@ const makeStrategy = ( const defaultApyRange = overrides.defaultApyRange ?? { min: 2, max: 8 } const config: ApyRangeConfig = { allowIdleReallocation: overrides.allowIdleReallocation ?? true, - capBufferPercent: 99.99, + capBufferWad: percentToWad(99.99), apyRange: (_vault, marketId) => { const range = overrides.marketApyRanges?.[marketId] ?? defaultApyRange return { min: percentToWad(range.min), max: percentToWad(range.max) } diff --git a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts index 4aeb63f7..3acf5754 100644 --- a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -1,6 +1,7 @@ import { getAddress, parseUnits } from 'viem' import { beforeEach, describe, expect, it } from 'vitest' +import { percentToWad } from '../../src/math' import { createEqualizeUtilizationsStrategy } from '../../src/strategies/equalize-utilizations' import { makeMarket, @@ -12,7 +13,7 @@ import { } from './helpers' const makeStrategy = (minUtilizationDeltaBips: (vault: `0x${string}`) => number = () => 0) => - createEqualizeUtilizationsStrategy({ capBufferPercent: 99.99, minUtilizationDeltaBips }) + createEqualizeUtilizationsStrategy({ capBufferWad: percentToWad(99.99), minUtilizationDeltaBips }) const WAD = 10n ** 18n diff --git a/bots/vault-v2-reallocation/test/strategies/helpers.ts b/bots/vault-v2-reallocation/test/strategies/helpers.ts index 98c3f182..ddead302 100644 --- a/bots/vault-v2-reallocation/test/strategies/helpers.ts +++ b/bots/vault-v2-reallocation/test/strategies/helpers.ts @@ -58,12 +58,7 @@ export const makeMarket = (opts: { id: makeMarketId(), capId: makeMarketId(), params: opts.params ?? makeMarketParams(), - state: { - totalSupplyAssets, - totalSupplyShares: totalSupplyAssets * 1_000_000n, // 1:1 ratio simplified - totalBorrowAssets, - totalBorrowShares: totalBorrowAssets * 1_000_000n - }, + 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 }, From d554ddd859ba1297854b38a0ae629222a8f48256 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 12:47:38 -0500 Subject: [PATCH 08/14] refactor(vault-v2-reallocation): strategies as classifiers over one reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the v1 framing: a strategy answers, per market, where it should sit and whether the move clears its min-delta threshold; one reconciler owns all mechanics — sizing, three-level cap pools with the dealloc-first credit rule as one explicit step, idle-balance netting (alloc ≤ dealloc + idle; dealloc excess parks or clamps per allowIdleParking), the contributing-markets gate, budget trim in market order, and delta-leg emission. Every pre-existing strategy test passes unmodified; 8 reconciler-direct tests added. Co-Authored-By: Claude Fable 5 --- .../src/strategies/apy-range.ts | 176 +++------------ .../src/strategies/equalize-utilizations.ts | 186 ++++----------- .../src/strategies/reconcile.ts | 155 +++++++++++++ .../test/strategies/reconcile.test.ts | 211 ++++++++++++++++++ 4 files changed, 441 insertions(+), 287 deletions(-) create mode 100644 bots/vault-v2-reallocation/src/strategies/reconcile.ts create mode 100644 bots/vault-v2-reallocation/test/strategies/reconcile.test.ts diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index 64b9b6e8..1c853dab 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -1,26 +1,14 @@ import type { Address, Hex } from 'viem' -import { MathLib } from '@morpho-org/blue-sdk' +import type { Strategy } from './strategy' -import type { VaultV2MarketData } from '../vault-data' -import type { Reallocation, ReallocationAction, Strategy } from './strategy' - -import { - apyToRate, - createDepositPools, - creditPools, - getDepositableAmount, - getUtilization, - getWithdrawableAmount, - rateToApy, - rateToUtilization, - takeFromPools, - utilizationToRate -} from '../math' +import { apyToRate, getUtilization, rateToApy, rateToUtilization, utilizationToRate } 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 } @@ -28,22 +16,6 @@ export type ApyRangeConfig = { minApyDeltaBips: (vault: Address, marketId: Hex) => number } -const { min } = MathLib - -/** Where a market's utilization sits relative to the utilization bounds its APY range implies. */ -const classifyMarket = ( - config: ApyRangeConfig, - vault: Address, - marketData: VaultV2MarketData -): { utilization: bigint; lowerBound: bigint; upperBound: bigint } => { - const apyRange = config.apyRange(vault, marketData.id) - return { - utilization: getUtilization(marketData.state), - lowerBound: rateToUtilization(apyToRate(apyRange.min), marketData.rateAtTarget), - upperBound: rateToUtilization(apyToRate(apyRange.max), marketData.rateAtTarget) - } -} - const apyDeltaBips = (from: bigint, to: bigint, rateAtTarget: bigint): number => Math.abs( Number( @@ -55,118 +27,38 @@ const apyDeltaBips = (from: bigint, to: bigint, rateAtTarget: bigint): number => /** * Keeps each market's borrow APY inside its configured range: converts the APY bounds to - * utilization bounds via the AdaptiveCurveIRM inverse, deallocates from markets below range, - * allocates into markets above range. Allocations may exceed deallocations by up to the vault's - * idle balance; excess deallocations park in idle unless `allowIdleReallocation` is off, in which - * case they are clamped to the allocation total. + * 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). Allocations respect the market, adapter-level, and - * collateral-level caps; capacity freed by this plan's own deallocations (executed first) counts. + * (see `isAdaptiveCurve` on the market data). */ -export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => { - return vaultData => { - const vault = vaultData.vaultAddress - const marketsData = vaultData.marketsData.filter(marketData => marketData.isAdaptiveCurve) - - // True only if at least one market that actually CONTRIBUTES assets moves enough — a capped-out - // or empty market must not arm the trigger for a plan whose real legs are all sub-threshold. - let didExceedMinApyDelta = false - - // Deallocations size first (the contract executes them first), crediting the freed capacity to - // the aggregate cap pools the allocation sizing then draws from. Both the sizing and leg passes - // walk markets in the same order with the same clamps, so the totals gathered here equal what - // the leg pass can actually emit. - let totalAmountToDeallocate = 0n - const sizingPools = createDepositPools(vaultData, config.capBufferWad) - for (const marketData of marketsData) { - const { utilization, lowerBound } = classifyMarket(config, vault, marketData) - if (utilization >= lowerBound) continue - const contribution = getWithdrawableAmount(marketData, lowerBound) - totalAmountToDeallocate += contribution - creditPools(sizingPools, marketData.params.collateralToken, contribution) - if (contribution > 0n) { - didExceedMinApyDelta ||= - apyDeltaBips(utilization, lowerBound, marketData.rateAtTarget) > - config.minApyDeltaBips(vault, marketData.id) - } - } - - let totalAmountToAllocate = 0n - for (const marketData of marketsData) { - const { utilization, upperBound } = classifyMarket(config, vault, marketData) - if (utilization <= upperBound) continue - const contribution = takeFromPools( - sizingPools, - marketData.params.collateralToken, - getDepositableAmount(marketData, vaultData.totalAssets, upperBound, config.capBufferWad) - ) - totalAmountToAllocate += contribution - if (contribution > 0n) { - didExceedMinApyDelta ||= - apyDeltaBips(utilization, upperBound, marketData.rateAtTarget) > - config.minApyDeltaBips(vault, marketData.id) - } - } - - if (totalAmountToDeallocate > totalAmountToAllocate && !config.allowIdleReallocation) { - totalAmountToDeallocate = totalAmountToAllocate - } else if (totalAmountToAllocate > totalAmountToDeallocate) { - totalAmountToAllocate = - totalAmountToDeallocate + - min(totalAmountToAllocate - totalAmountToDeallocate, vaultData.idleAssets) - } - - if (min(totalAmountToDeallocate, totalAmountToAllocate) === 0n || !didExceedMinApyDelta) { - return undefined - } - - let remainingAmountToDeallocate = totalAmountToDeallocate - let remainingAmountToAllocate = totalAmountToAllocate - - const allocations: ReallocationAction[] = [] - const deallocations: ReallocationAction[] = [] - - const legPools = createDepositPools(vaultData, config.capBufferWad) - for (const marketData of marketsData) { - if (remainingAmountToDeallocate === 0n) break - const { utilization, lowerBound } = classifyMarket(config, vault, marketData) - if (utilization >= lowerBound) continue - const toDeallocate = min( - getWithdrawableAmount(marketData, lowerBound), - remainingAmountToDeallocate - ) - remainingAmountToDeallocate -= toDeallocate - creditPools(legPools, marketData.params.collateralToken, toDeallocate) - if (toDeallocate > 0n) { - deallocations.push({ - marketId: marketData.id, - marketParams: marketData.params, - assets: toDeallocate - }) +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 bound = + utilization > upperBound ? upperBound : utilization < lowerBound ? lowerBound : undefined + if (bound === undefined) return undefined + + return { + targetUtilization: bound, + clearsMinDelta: + apyDeltaBips(utilization, bound, rateAtTarget) > + config.minApyDeltaBips(vaultAddress, marketData.id) + } } - } - - for (const marketData of marketsData) { - if (remainingAmountToAllocate === 0n) break - const { utilization, upperBound } = classifyMarket(config, vault, marketData) - if (utilization <= upperBound) continue - const desired = min( - getDepositableAmount(marketData, vaultData.totalAssets, upperBound, config.capBufferWad), - remainingAmountToAllocate - ) - const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) - remainingAmountToAllocate -= toAllocate - if (toAllocate > 0n) { - allocations.push({ - marketId: marketData.id, - marketParams: marketData.params, - assets: toAllocate - }) - } - } - - return { allocations, deallocations } satisfies Reallocation - } -} + }) diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts index 662b4836..c1c12c27 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -3,18 +3,14 @@ import type { Address } from 'viem' import { MathLib } from '@morpho-org/blue-sdk' import { isAddressEqual, zeroAddress } from 'viem' -import type { Reallocation, ReallocationAction, Strategy } from './strategy' +import type { VaultV2MarketData } from '../vault-data' +import type { Strategy } from './strategy' -import { - createDepositPools, - creditPools, - getDepositableAmount, - getUtilization, - getWithdrawableAmount, - takeFromPools -} from '../math' +import { getUtilization } 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 @@ -22,146 +18,46 @@ type EqualizeUtilizationsConfig = { const { min, WAD, wDivDown } = MathLib +const isRealCollateral = (marketData: VaultV2MarketData): boolean => + !isAddressEqual(marketData.params.collateralToken, zeroAddress) + /** * 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. Allocations may - * exceed deallocations by at most the vault's idle balance (`allocate` pulls from vault balance, - * so anything beyond that reverts); excess deallocations park in idle. Fires only when at least - * one market's deviation exceeds the vault's min-delta threshold. Allocations respect the market, - * adapter-level, and collateral-level caps; capacity freed by this plan's own deallocations - * (executed first) counts. + * (`sum(totalBorrowAssets) / (sum(totalSupplyAssets) + idleAssets)`, clamped at 100% — 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 => { - return vaultData => { - const marketsData = vaultData.marketsData.filter( - marketData => !isAddressEqual(marketData.params.collateralToken, zeroAddress) - ) - - 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 - // Aggregate utilization exceeds 100% in bad-debt states; sizing deallocations toward a >100% - // target asks for more than the markets hold, so every resulting plan reverts. - const targetUtilization = min(wDivDown(totalBorrow, totalSupply), WAD) - - // True only if at least one market that actually CONTRIBUTES assets deviates enough — a - // capped-out or empty market must not arm the trigger for a plan whose real legs are all - // sub-threshold. - let didExceedMinUtilizationDelta = false - const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) - const deviationBips = (utilization: bigint): number => - Math.abs(Number((utilization - targetUtilization) / 1_000_000_000n) / 1e5) - - // Deallocations size first (the contract executes them first), crediting the freed capacity to - // the aggregate cap pools the allocation sizing then draws from. Both the sizing and leg passes - // walk markets in the same order with the same clamps, so the totals gathered here equal what - // the leg pass can actually emit. - let totalAmountToDeallocate = 0n - const sizingPools = createDepositPools(vaultData, config.capBufferWad) - for (const marketData of marketsData) { - const utilization = getUtilization(marketData.state) - if (utilization > targetUtilization) continue - const contribution = getWithdrawableAmount(marketData, targetUtilization) - totalAmountToDeallocate += contribution - creditPools(sizingPools, marketData.params.collateralToken, contribution) - if (contribution > 0n) { - didExceedMinUtilizationDelta ||= deviationBips(utilization) > minUtilizationDeltaBips - } - } - - let totalAmountToAllocate = 0n - for (const marketData of marketsData) { - const utilization = getUtilization(marketData.state) - if (utilization <= targetUtilization) continue - const contribution = takeFromPools( - sizingPools, - marketData.params.collateralToken, - getDepositableAmount( - marketData, - vaultData.totalAssets, - targetUtilization, - config.capBufferWad - ) - ) - totalAmountToAllocate += contribution - if (contribution > 0n) { - didExceedMinUtilizationDelta ||= deviationBips(utilization) > minUtilizationDeltaBips - } - } - - // `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. - if (totalAmountToAllocate > totalAmountToDeallocate) { - totalAmountToAllocate = - totalAmountToDeallocate + - min(totalAmountToAllocate - totalAmountToDeallocate, vaultData.idleAssets) - } - - if ( - min(totalAmountToDeallocate, totalAmountToAllocate) === 0n || - !didExceedMinUtilizationDelta - ) { - return undefined - } - - let remainingAmountToDeallocate = totalAmountToDeallocate - let remainingAmountToAllocate = totalAmountToAllocate - - const allocations: ReallocationAction[] = [] - const deallocations: ReallocationAction[] = [] - - const legPools = createDepositPools(vaultData, config.capBufferWad) - for (const marketData of marketsData) { - if (remainingAmountToDeallocate === 0n) break - const utilization = getUtilization(marketData.state) - if (utilization > targetUtilization) continue - const toDeallocate = min( - getWithdrawableAmount(marketData, targetUtilization), - remainingAmountToDeallocate +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 ) - remainingAmountToDeallocate -= toDeallocate - creditPools(legPools, marketData.params.collateralToken, toDeallocate) - if (toDeallocate > 0n) { - deallocations.push({ - marketId: marketData.id, - marketParams: marketData.params, - assets: toDeallocate - }) - } - } - - for (const marketData of marketsData) { - if (remainingAmountToAllocate === 0n) break - const utilization = getUtilization(marketData.state) - if (utilization <= targetUtilization) continue - const desired = min( - getDepositableAmount( - marketData, - vaultData.totalAssets, + 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) + // Aggregate utilization exceeds 100% in bad-debt states; sizing deallocations toward a >100% + // target asks for more than the markets hold, so every resulting plan reverts. + const targetUtilization = min(wDivDown(totalBorrow, totalSupply), WAD) + + return marketData => { + if (!isRealCollateral(marketData)) return undefined + return { targetUtilization, - config.capBufferWad - ), - remainingAmountToAllocate - ) - const toAllocate = takeFromPools(legPools, marketData.params.collateralToken, desired) - remainingAmountToAllocate -= toAllocate - if (toAllocate > 0n) { - allocations.push({ - marketId: marketData.id, - marketParams: marketData.params, - assets: toAllocate - }) + clearsMinDelta: + Math.abs( + Number((getUtilization(marketData.state) - targetUtilization) / 1_000_000_000n) / 1e5 + ) > minUtilizationDeltaBips + } } } - - return { allocations, deallocations } satisfies Reallocation - } -} + }) 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..ede8bb03 --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -0,0 +1,155 @@ +import { MathLib } from '@morpho-org/blue-sdk' + +import type { VaultV2Data, VaultV2MarketData } from '../vault-data' +import type { Reallocation, ReallocationAction, Strategy } from './strategy' + +import { + createDepositPools, + creditPools, + getDepositableAmount, + getUtilization, + getWithdrawableAmount, + takeFromPools +} from '../math' + +/** Where one market should sit, and whether getting it there is worth a transaction. */ +export type MarketTarget = { + targetUtilization: bigint + /** Whether this market's own move clears the strategy's min-delta threshold. */ + clearsMinDelta: 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 + side: 'allocate' | 'deallocate' + amount: bigint +} + +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, applies the min-delta firing gate over contributing markets only, trims both sides + * to the shared budget in market order, and emits the `{allocations, deallocations}` delta legs. + * + * 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 clamp. + */ +export const createReconciler = (options: ReconcilerOptions): Strategy => { + return vaultData => { + const classify = options.classifierFor(vaultData) + const classified = vaultData.marketsData.flatMap(marketData => { + const target = classify(marketData) + if (target === undefined) return [] + const utilization = getUtilization(marketData.state) + // At the target exactly there is nothing to move — skip before sizing. + if (utilization === target.targetUtilization) return [] + return [{ marketData, target, utilization }] + }) + + const moves: SizedMove[] = [] + let totalAmountToDeallocate = 0n + let totalAmountToAllocate = 0n + let didClearMinDelta = false // true if *at least one contributing* market moves enough + + const sizingPools = createDepositPools(vaultData, options.capBufferWad) + for (const { marketData, target, utilization } of classified) { + if (utilization > target.targetUtilization) continue + const amount = getWithdrawableAmount(marketData, target.targetUtilization) + totalAmountToDeallocate += amount + creditPools(sizingPools, marketData.params.collateralToken, amount) + if (amount > 0n) { + didClearMinDelta ||= target.clearsMinDelta + moves.push({ marketData, side: 'deallocate', amount }) + } + } + for (const { marketData, target, utilization } of classified) { + if (utilization < target.targetUtilization) continue + const amount = takeFromPools( + sizingPools, + marketData.params.collateralToken, + getDepositableAmount( + marketData, + vaultData.totalAssets, + target.targetUtilization, + options.capBufferWad + ) + ) + totalAmountToAllocate += amount + if (amount > 0n) { + didClearMinDelta ||= target.clearsMinDelta + moves.push({ marketData, side: 'allocate', amount }) + } + } + + 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 || !didClearMinDelta) { + return undefined + } + + let remainingAmountToDeallocate = totalAmountToDeallocate + let remainingAmountToAllocate = totalAmountToAllocate + + const allocations: ReallocationAction[] = [] + const deallocations: ReallocationAction[] = [] + + const legPools = createDepositPools(vaultData, options.capBufferWad) + for (const move of moves) { + if (move.side !== 'deallocate') continue + if (remainingAmountToDeallocate === 0n) break + const toDeallocate = min(move.amount, remainingAmountToDeallocate) + remainingAmountToDeallocate -= toDeallocate + creditPools(legPools, move.marketData.params.collateralToken, toDeallocate) + if (toDeallocate > 0n) deallocations.push(toLeg(move, toDeallocate)) + } + for (const move of moves) { + if (move.side !== 'allocate') continue + if (remainingAmountToAllocate === 0n) break + const toAllocate = takeFromPools( + legPools, + move.marketData.params.collateralToken, + min(move.amount, remainingAmountToAllocate) + ) + remainingAmountToAllocate -= toAllocate + if (toAllocate > 0n) allocations.push(toLeg(move, toAllocate)) + } + + return { allocations, deallocations } satisfies Reallocation + } +} 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..9b1546e9 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -0,0 +1,211 @@ +import { parseUnits } from 'viem' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { Classify } from '../../src/strategies/reconcile' + +import { percentToWad } from '../../src/math' +import { createReconciler } from '../../src/strategies/reconcile' +import { makeMarket, makeVaultData, RATE_AT_TARGET, resetMarketCounter } from './helpers' + +const WAD = 10n ** 18n + +const makeReconciler = ( + classify: Classify, + overrides: Partial<{ allowIdleParking: boolean; capBufferWad: bigint }> = {} +) => + createReconciler({ + capBufferWad: overrides.capBufferWad ?? percentToWad(99.99), + allowIdleParking: overrides.allowIdleParking ?? true, + classifierFor: () => classify + }) + +// Every market converges on 50% utilization and always clears the gate. +const toHalf: Classify = () => ({ targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true }) + +describe('createReconciler', () => { + beforeEach(() => { + resetMarketCounter() + }) + + 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 + : { targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true } + // 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 => ({ + targetUtilization: (50n * WAD) / 100n, + clearsMinDelta: marketData.id === gateOnlyMarket.id + }) + expect( + makeReconciler(classify)(makeVaultData([gateOnlyMarket, hotMarket, coldMarket])) + ).toBeUndefined() + }) + + 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('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]) + }) +}) From 5406ed4528be9c340bbe6d7690ea37548a42a959 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Mon, 17 Aug 2026 14:59:20 -0500 Subject: [PATCH 09/14] fix(vault-v2-reallocation): mirror v1 pr review feedback Typed RailwayDeploymentError replaces plain Errors in the deploy script with untrusted CLI stderr retained only as cause (setSecret stays detail-free); deploy helpers become arrow constants with viem key validation; compose forwards every documented runtime knob; intEnv rejects unsafe integers; the reconciler's min-delta gate is evaluated on the TRIMMED legs so a fully trimmed-out clearing market cannot authorize a plan of sub-threshold legs. Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/docker-compose.yml | 10 ++ .../scripts/deploy-railway.ts | 139 +++++++++--------- .../scripts/railway-deployment.error.ts | 12 ++ bots/vault-v2-reallocation/src/config.ts | 5 + .../src/strategies/reconcile.ts | 37 +++-- .../vault-v2-reallocation/test/config.test.ts | 1 + .../test/strategies/reconcile.test.ts | 53 +++++++ 7 files changed, 173 insertions(+), 84 deletions(-) create mode 100644 bots/vault-v2-reallocation/scripts/railway-deployment.error.ts diff --git a/bots/vault-v2-reallocation/docker-compose.yml b/bots/vault-v2-reallocation/docker-compose.yml index a64dc66f..f206ea9a 100644 --- a/bots/vault-v2-reallocation/docker-compose.yml +++ b/bots/vault-v2-reallocation/docker-compose.yml @@ -10,10 +10,15 @@ services: 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:-} + MIN_APY_DELTA_BIPS: ${MIN_APY_DELTA_BIPS:-} + MIN_UTILIZATION_DELTA_BIPS: ${MIN_UTILIZATION_DELTA_BIPS:-} + ALLOW_IDLE_REALLOCATION: ${ALLOW_IDLE_REALLOCATION:-} + MAX_FEE_GWEI: ${MAX_FEE_GWEI:-} # 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} @@ -32,10 +37,15 @@ services: 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:-} + MIN_APY_DELTA_BIPS: ${MIN_APY_DELTA_BIPS:-} + MIN_UTILIZATION_DELTA_BIPS: ${MIN_UTILIZATION_DELTA_BIPS:-} + ALLOW_IDLE_REALLOCATION: ${ALLOW_IDLE_REALLOCATION:-} + MAX_FEE_GWEI: ${MAX_FEE_GWEI:-} DRY_RUN: ${DRY_RUN_8453:-true} LOG_LEVEL: ${LOG_LEVEL:-info} BETTERSTACK_SOURCE_TOKEN: ${BETTERSTACK_SOURCE_TOKEN:-} diff --git a/bots/vault-v2-reallocation/scripts/deploy-railway.ts b/bots/vault-v2-reallocation/scripts/deploy-railway.ts index 9601c42e..d472aba3 100644 --- a/bots/vault-v2-reallocation/scripts/deploy-railway.ts +++ b/bots/vault-v2-reallocation/scripts/deploy-railway.ts @@ -29,68 +29,45 @@ * re-synchronizes STRATEGY / DRY_RUN / VAULT_WHITELIST from this run's inputs. * * Secret hygiene: secrets (per-chain RPC_URL, REALLOCATOR_PRIVATE_KEY) are piped to - * `railway variable set --stdin` so their values never appear in argv; on failure we surface only - * the variable key, never its value; variable values are never logged. + * `railway variable set --stdin` so their values never appear in argv; failures surface only the + * variable key and the phase, with the raw CLI error retained as the thrown error's `cause`; + * variable values are never logged. */ import { delay, tryCatch } from '@repo/utils' import { $ } from 'execa' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { isHex } from 'viem' -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)), '..', '..', '..') +import { RailwayDeploymentError } from './railway-deployment.error' type Env = Record type RailwayService = { id: string; name: string } -function required(env: Env, name: string): 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 Error(`Missing required env var: ${name}`) + if (!value || !value.trim()) throw new RailwayDeploymentError(`Missing required env var: ${name}`) return value.trim() } -// 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. -function serviceName(productionName: string): string { - return ENVIRONMENT === 'production' - ? productionName - : `${ENVIRONMENT}-${productionName.toLowerCase()}` -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} +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]'). -function str(value: unknown): string { - return typeof value === 'string' ? value : '' -} +const str = (value: unknown): string => (typeof value === 'string' ? value : '') -// Surface a failed `railway` command's stderr so failures are actionable. Safe for non-secret -// commands; never used on setSecret. -function stderrOf(error: unknown): string { - if (isRecord(error) && 'stderr' in error) { - const s = (error as { stderr: unknown }).stderr - if (typeof s === 'string' && s.trim()) return s.trim() - if (s instanceof Uint8Array) { - const text = Buffer.from(s).toString('utf8').trim() - if (text) return text - } - } - return error instanceof Error ? error.message : String(error) -} - -function assertPrivateKey(key: string): void { - if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { - throw new Error('REALLOCATOR_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') +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' + ) } } -function parseServices(raw: string): RailwayService[] { +const parseServices = (raw: string): RailwayService[] => { const { data } = tryCatch(() => JSON.parse(raw) as unknown) const rows = Array.isArray(data) ? data @@ -103,7 +80,7 @@ function parseServices(raw: string): RailwayService[] { .filter(service => service.name) } -function parseLatestStatus(raw: string): string { +const parseLatestStatus = (raw: string): string => { const { data } = tryCatch(() => JSON.parse(raw) as unknown) const rows = Array.isArray(data) ? data @@ -114,71 +91,83 @@ function parseLatestStatus(raw: string): string { return latest ? str(latest.status) || 'UNKNOWN' : 'UNKNOWN' } -async function assertCli(): Promise { +const assertCli = async (): Promise => { const { error } = await tryCatch($`railway --version`) if (error) - throw new Error('Railway CLI not found. Install it: https://docs.railway.com/guides/cli') + 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. -async function ensureContext(): Promise { +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 Error( - `Failed to link ${PROJECT_ID} (${ENVIRONMENT}). Set RAILWAY_TOKEN or run \`railway login\`.` + 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}).`) } -async function listServices(): Promise { +const listServices = async (): Promise => { const { data, error } = await tryCatch($`railway service list --json`.then(r => r.stdout)) return error || typeof data !== 'string' ? [] : parseServices(data) } -async function ensureService(name: string): Promise { +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 Error(`Failed to create service ${name}: ${stderrOf(error)}`) + 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. -async function setVar(service: string, kv: string): Promise { +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 Error(`Failed to set ${key} on ${service}: ${stderrOf(error)}`) + if (error) + throw new RailwayDeploymentError(`Failed to set ${key} on ${service}`, { cause: error }) console.log(`Set ${key} on ${service}.`) } -// Secret variable: value piped via stdin (never argv), `--json` omitted (it echoes raw values). -async function setSecret(service: string, key: string, value: string): Promise { +// 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 Error(`Failed to set ${key} on ${service}`) + if (error) throw new RailwayDeploymentError(`Failed to set ${key} on ${service}`) console.log(`Set ${key} on ${service} (secret).`) } -async function deployService(service: string): Promise { +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 Error(`Failed to start deploy for ${service}: ${stderrOf(error)}`) + if (error) + throw new RailwayDeploymentError(`Failed to start deploy for ${service}`, { cause: error }) } -async function latestStatus(service: string): Promise { +const latestStatus = async (service: string): Promise => { // -e/-p explicit for the same reason as deployService: don't depend on ambient link state. const args = [ 'railway', @@ -199,11 +188,11 @@ async function latestStatus(service: string): Promise { } // Bounded poll (default 10 min) on an awaited timer — never a foreground shell sleep, so CI can't hang. -async function waitForDeploy( +const waitForDeploy = async ( service: string, maxAttempts = 60, intervalMs = 10_000 -): Promise { +): Promise => { for (let attempt = 1; attempt <= maxAttempts; attempt++) { const status = await latestStatus(service) if (status === 'SUCCESS' || status === 'FAILED' || status === 'CRASHED') return status @@ -219,6 +208,23 @@ async function waitForDeploy( 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 @@ -245,17 +251,6 @@ if (/^(1|true)$/i.test(process.env.DEPLOY_ONLY?.trim() ?? '')) { process.exit([...statuses.values()].some(badStatus) ? 1 : 0) } -// 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. -function suffixed(name: string, chainId: number): string | undefined { - return process.env[`${name}_${chainId}`]?.trim() || undefined -} -function requiredSuffixed(name: string, chainId: number): string { - const value = suffixed(name, chainId) - if (!value) throw new Error(`Missing required env var: ${name}_${chainId}`) - return value -} - // 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) @@ -265,7 +260,7 @@ const chainSecrets = CHAINS.map(chain => { suffixed('REALLOCATOR_PRIVATE_KEY', chain.chainId) ?? process.env.REALLOCATOR_PRIVATE_KEY?.trim() if (!reallocatorPrivateKey) - throw new Error( + throw new RailwayDeploymentError( `Missing required env var: REALLOCATOR_PRIVATE_KEY_${chain.chainId} (or a shared REALLOCATOR_PRIVATE_KEY)` ) assertPrivateKey(reallocatorPrivateKey) 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 index 25bc1aff..d7dfed9e 100644 --- a/bots/vault-v2-reallocation/src/config.ts +++ b/bots/vault-v2-reallocation/src/config.ts @@ -82,6 +82,11 @@ const intEnv = ( 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]}`) } diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts index ede8bb03..c0adb052 100644 --- a/bots/vault-v2-reallocation/src/strategies/reconcile.ts +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -39,6 +39,7 @@ type SizedMove = { marketData: VaultV2MarketData side: 'allocate' | 'deallocate' amount: bigint + clearsMinDelta: boolean } const { min } = MathLib @@ -52,8 +53,12 @@ const toLeg = ({ marketData }: SizedMove, assets: bigint): ReallocationAction => /** * 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, applies the min-delta firing gate over contributing markets only, trims both sides - * to the shared budget in market order, and emits the `{allocations, deallocations}` delta legs. + * 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: a market whose move clears the + * threshold but whose take is entirely consumed by the budget or the cap pools cannot arm the + * plan, so a fired plan always contains at least one surviving 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 @@ -77,7 +82,6 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { const moves: SizedMove[] = [] let totalAmountToDeallocate = 0n let totalAmountToAllocate = 0n - let didClearMinDelta = false // true if *at least one contributing* market moves enough const sizingPools = createDepositPools(vaultData, options.capBufferWad) for (const { marketData, target, utilization } of classified) { @@ -86,8 +90,12 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { totalAmountToDeallocate += amount creditPools(sizingPools, marketData.params.collateralToken, amount) if (amount > 0n) { - didClearMinDelta ||= target.clearsMinDelta - moves.push({ marketData, side: 'deallocate', amount }) + moves.push({ + marketData, + side: 'deallocate', + amount, + clearsMinDelta: target.clearsMinDelta + }) } } for (const { marketData, target, utilization } of classified) { @@ -104,8 +112,7 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { ) totalAmountToAllocate += amount if (amount > 0n) { - didClearMinDelta ||= target.clearsMinDelta - moves.push({ marketData, side: 'allocate', amount }) + moves.push({ marketData, side: 'allocate', amount, clearsMinDelta: target.clearsMinDelta }) } } @@ -119,12 +126,11 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { min(totalAmountToAllocate - totalAmountToDeallocate, vaultData.idleAssets) } - if (min(totalAmountToDeallocate, totalAmountToAllocate) === 0n || !didClearMinDelta) { - return undefined - } + if (min(totalAmountToDeallocate, totalAmountToAllocate) === 0n) return undefined let remainingAmountToDeallocate = totalAmountToDeallocate let remainingAmountToAllocate = totalAmountToAllocate + let didClearMinDelta = false // true if *at least one surviving* leg moves enough const allocations: ReallocationAction[] = [] const deallocations: ReallocationAction[] = [] @@ -136,7 +142,10 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { const toDeallocate = min(move.amount, remainingAmountToDeallocate) remainingAmountToDeallocate -= toDeallocate creditPools(legPools, move.marketData.params.collateralToken, toDeallocate) - if (toDeallocate > 0n) deallocations.push(toLeg(move, toDeallocate)) + if (toDeallocate > 0n) { + didClearMinDelta ||= move.clearsMinDelta + deallocations.push(toLeg(move, toDeallocate)) + } } for (const move of moves) { if (move.side !== 'allocate') continue @@ -147,9 +156,13 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { min(move.amount, remainingAmountToAllocate) ) remainingAmountToAllocate -= toAllocate - if (toAllocate > 0n) allocations.push(toLeg(move, toAllocate)) + if (toAllocate > 0n) { + didClearMinDelta ||= move.clearsMinDelta + allocations.push(toLeg(move, toAllocate)) + } } + if (!didClearMinDelta) return undefined return { allocations, deallocations } satisfies Reallocation } } diff --git a/bots/vault-v2-reallocation/test/config.test.ts b/bots/vault-v2-reallocation/test/config.test.ts index 5ba24bad..67cc1510 100644 --- a/bots/vault-v2-reallocation/test/config.test.ts +++ b/bots/vault-v2-reallocation/test/config.test.ts @@ -69,6 +69,7 @@ describe('loadConfig', () => { ['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' }] diff --git a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts index 9b1546e9..24a5356e 100644 --- a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -116,6 +116,59 @@ describe('createReconciler', () => { ).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 => ({ + targetUtilization: (50n * WAD) / 100n, + clearsMinDelta: marketData.id === hotA.id + }) + 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 => ({ + targetUtilization: (50n * WAD) / 100n, + clearsMinDelta: marketData.id === hotA.id + }) + 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, From f3f84573088a35205345ce67cf774576531615f5 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Tue, 18 Aug 2026 00:11:30 -0500 Subject: [PATCH 10/14] fix(vault-v2-reallocation): clamp classifier targets to realizable utilization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WAD target — an APY bound past the curve max on a decayed-rateAtTarget cold market, or a bad-debt aggregate at/above 100% — sizes a deallocation to the market's entire free liquidity: exact to the snapshot, unrealizable one accrual later, a sim-passes-then-reverts loop. The reconciler now clamps every classifier target to MAX_TARGET_UTILIZATION (99.9%, ~10 bips of borrow-side accrual margin), replacing equalize's WAD clamp; feasibility is the reconciler's job, not the classifiers'. Co-Authored-By: Claude Fable 5 --- .../src/strategies/equalize-utilizations.ts | 10 +++---- .../src/strategies/reconcile.ts | 20 +++++++++++-- .../strategies/equalize-utilizations.test.ts | 24 +++++++++++++++ .../test/strategies/reconcile.test.ts | 30 +++++++++++++++++++ 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts index c1c12c27..e799301f 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -16,14 +16,14 @@ type EqualizeUtilizationsConfig = { minUtilizationDeltaBips: (vault: Address) => number } -const { min, WAD, wDivDown } = MathLib +const { wDivDown } = MathLib const isRealCollateral = (marketData: VaultV2MarketData): boolean => !isAddressEqual(marketData.params.collateralToken, zeroAddress) /** * Converges every market toward the vault-wide average utilization - * (`sum(totalBorrowAssets) / (sum(totalSupplyAssets) + idleAssets)`, clamped at 100% — idle counts + * (`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 @@ -45,9 +45,9 @@ export const createEqualizeUtilizationsStrategy = (config: EqualizeUtilizationsC if (totalSupply === 0n || totalBorrow === 0n) return () => undefined const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) - // Aggregate utilization exceeds 100% in bad-debt states; sizing deallocations toward a >100% - // target asks for more than the markets hold, so every resulting plan reverts. - const targetUtilization = min(wDivDown(totalBorrow, totalSupply), WAD) + // May exceed WAD in bad-debt states — the reconciler clamps every target to what a leg can + // realize, so the raw aggregate is reported as-is. + const targetUtilization = wDivDown(totalBorrow, totalSupply) return marketData => { if (!isRealCollateral(marketData)) return undefined diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts index c0adb052..d76a7cd7 100644 --- a/bots/vault-v2-reallocation/src/strategies/reconcile.ts +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -9,9 +9,20 @@ import { getDepositableAmount, getUtilization, getWithdrawableAmount, + percentToWad, takeFromPools } from '../math' +/** + * 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 + * the 99.99% cap buffer, whose sliver absorbs supply-side drift instead. + */ +const MAX_TARGET_UTILIZATION = percentToWad(99.9) + /** Where one market should sit, and whether getting it there is worth a transaction. */ export type MarketTarget = { targetUtilization: bigint @@ -71,8 +82,13 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { return vaultData => { const classify = options.classifierFor(vaultData) const classified = vaultData.marketsData.flatMap(marketData => { - const target = classify(marketData) - if (target === undefined) return [] + const verdict = classify(marketData) + if (verdict === undefined) return [] + // Feasibility is the reconciler's job: every target is clamped to what a leg can realize. + const target = { + ...verdict, + targetUtilization: min(verdict.targetUtilization, MAX_TARGET_UTILIZATION) + } const utilization = getUtilization(marketData.state) // At the target exactly there is nothing to move — skip before sizing. if (utilization === target.targetUtilization) return [] diff --git a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts index 3acf5754..8708f765 100644 --- a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -87,6 +87,30 @@ describe('createEqualizeUtilizationsStrategy', () => { } }) + 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('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 diff --git a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts index 24a5356e..62d09365 100644 --- a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -27,6 +27,36 @@ describe('createReconciler', () => { resetMarketCounter() }) + 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 = () => ({ targetUtilization: WAD, clearsMinDelta: true }) + 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) + : { targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true } + 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('sizes both sides toward the classifier target and emits delta legs', () => { const hotMarket = makeMarket({ utilization: (90n * WAD) / 100n, From 6365f756569669eb6f88fd9b0ebb3288c972cf09 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Tue, 18 Aug 2026 01:13:37 -0500 Subject: [PATCH 11/14] fix(vault-v2-reallocation): mirror v1 review execution batch Mandatory bot-kit consumers: Policy.targets rename and boolean queue.submit (submitted counted only on real broadcasts, reallocation.not_broadcast on refusal). Read client opts into batched JSON-RPC for the per-cap-id fan-out. Whitelist dedupes case-variant addresses; vaults process concurrently via allSettled with per-vault counter folding; the role read runs alongside the fetch. SDK-first math (SECONDS_PER_YEAR, wholePercentToWAD, zeroFloorSub), wadToBips dedupes the bips idiom, CAP_BUFFER_WAD lives in math, isIdle and the foreign-IRM id list ride the snapshot. Startup checks extracted to vault-checks.ts and the time gate to interval-gate.ts, both unit-tested; strategy-config tables get shape validation; stale narration pruned. Co-Authored-By: Claude Fable 5 --- .../scripts/deploy-railway.ts | 31 +---- bots/vault-v2-reallocation/src/config.ts | 19 +-- bots/vault-v2-reallocation/src/index.ts | 98 ++++++------- .../src/interval-gate.ts | 17 +++ bots/vault-v2-reallocation/src/math.ts | 29 ++-- bots/vault-v2-reallocation/src/runner/tick.ts | 130 ++++++++++-------- .../src/strategies/apy-range.ts | 18 ++- .../src/strategies/equalize-utilizations.ts | 7 +- .../src/strategies/index.ts | 11 +- .../src/strategies/reconcile.ts | 4 +- .../vault-v2-reallocation/src/vault-checks.ts | 57 ++++++++ bots/vault-v2-reallocation/src/vault-data.ts | 24 +++- .../vault-v2-reallocation/test/config.test.ts | 5 + .../vault-v2-reallocation/test/encode.test.ts | 8 +- .../test/interval-gate.test.ts | 40 ++++++ bots/vault-v2-reallocation/test/math.test.ts | 13 +- .../test/runner/tick.test.ts | 17 ++- .../test/strategies/apy-range.test.ts | 17 +-- .../strategies/equalize-utilizations.test.ts | 22 +-- .../test/strategies/helpers.ts | 15 +- .../test/strategies/reconcile.test.ts | 12 +- .../test/strategy-config.test.ts | 12 ++ .../test/vault-checks.test.ts | 77 +++++++++++ packages/bot-kit/test/policy.test.ts | 4 +- 24 files changed, 442 insertions(+), 245 deletions(-) create mode 100644 bots/vault-v2-reallocation/src/interval-gate.ts create mode 100644 bots/vault-v2-reallocation/src/vault-checks.ts create mode 100644 bots/vault-v2-reallocation/test/interval-gate.test.ts create mode 100644 bots/vault-v2-reallocation/test/vault-checks.test.ts diff --git a/bots/vault-v2-reallocation/scripts/deploy-railway.ts b/bots/vault-v2-reallocation/scripts/deploy-railway.ts index d472aba3..35b2a6bb 100644 --- a/bots/vault-v2-reallocation/scripts/deploy-railway.ts +++ b/bots/vault-v2-reallocation/scripts/deploy-railway.ts @@ -1,23 +1,6 @@ /** - * Reproducible, idempotent deployment of the multi-chain vault-v2-reallocation system to a Railway - * project: one `bot-` runner per chain (see CHAINS below). All data comes over RPC, so - * there is no Postgres or indexer service to provision. - * - * Runs anywhere with the `railway` CLI installed and authenticated. The target project is supplied - * entirely via env vars — no project identifier is baked into this (open-source) file: - * - RAILWAY_PROJECT_ID (required) selects the project; RAILWAY_ENVIRONMENT defaults to `production`. - * - CI / unattended: set RAILWAY_TOKEN (a project token scoped to that project / environment). - * - Local: an interactive `railway login` session; the script links the project by id. - * - * Per-chain env vars are chainId-suffixed (endpoints/whitelists differ per chain): - * - RPC_URL_ (required per chain) - * - VAULT_WHITELIST_ (required per chain) — comma-separated VaultV2 vaults - * - REALLOCATOR_PRIVATE_KEY_ (per chain) OR a shared REALLOCATOR_PRIVATE_KEY fallback; - * the EOA must hold the allocator role on every whitelisted vault - * - STRATEGY_ (optional; defaults to equalize-utilizations) - * - DRY_RUN_ (optional; defaults to true — flip to false once the logged - * reallocation.dry_run plans look right) - * - BETTERSTACK_HEARTBEAT_URL_ (optional) + * 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 @@ -25,13 +8,9 @@ * 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). * - * Idempotent: existing services / variables are reused; each run redeploys every bot and - * re-synchronizes STRATEGY / DRY_RUN / VAULT_WHITELIST from this run's inputs. - * - * Secret hygiene: secrets (per-chain RPC_URL, REALLOCATOR_PRIVATE_KEY) are piped to - * `railway variable set --stdin` so their values never appear in argv; failures surface only the - * variable key and the phase, with the raw CLI error retained as the thrown error's `cause`; - * variable values are never logged. + * Secrets (per-chain RPC_URL, 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 CI re-ships + * already-provisioned services) no variable is written at all, so CI needs no secret to redeploy. */ import { delay, tryCatch } from '@repo/utils' import { $ } from 'execa' diff --git a/bots/vault-v2-reallocation/src/config.ts b/bots/vault-v2-reallocation/src/config.ts index d7dfed9e..6b88ef59 100644 --- a/bots/vault-v2-reallocation/src/config.ts +++ b/bots/vault-v2-reallocation/src/config.ts @@ -21,9 +21,6 @@ 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. The old repo's -// EXECUTION_INTERVAL was documented in seconds but consumed as minutes; naming the unit here -// resolves that ambiguity by construction. 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 @@ -31,13 +28,6 @@ const DEFAULT_MIN_APY_DELTA_BIPS = 25 // target by more than this (bips). const DEFAULT_MIN_UTILIZATION_DELTA_BIPS = 250 -/** - * Deposit legs stop just short of each market's supply cap: the cap is scaled by this percentage - * 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_PERCENT = 99.99 - type Env = Record export type Config = { @@ -107,8 +97,9 @@ const boolEnv = (env: Env, name: string, def: boolean): boolean => { return raw === 'true' } -// Parses a required comma-separated list of addresses into checksummed `Address`es. Fails loud on -// any malformed element and on an empty result — an empty whitelist would silently no-op every tick. +// 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(',') @@ -123,7 +114,9 @@ const addressListEnv = (env: Env, name: string): Address[] => { if (addresses.length === 0) { throw new InvalidConfigError(`${name} must contain at least one address`) } - return addresses + // 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 => diff --git a/bots/vault-v2-reallocation/src/index.ts b/bots/vault-v2-reallocation/src/index.ts index 261cc639..1e9a0de9 100644 --- a/bots/vault-v2-reallocation/src/index.ts +++ b/bots/vault-v2-reallocation/src/index.ts @@ -16,16 +16,18 @@ import { railwayContext, simulateCall } from '@repo/bot-kit' -import { ensureError, tryCatch } from '@repo/utils' +import { ensureError } from '@repo/utils' import { getAbiItem, toFunctionSelector } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' import { getBlockNumber, readContract } from 'viem/actions' import { loadConfig } from './config' import { encodeReallocation } from './encode' -import { InvalidVaultError } from './invalid-vault.error' +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 @@ -34,41 +36,49 @@ const SETTLED_COOLDOWN_BLOCKS = 20n async function main() { const config = loadConfig() - // Global wide-log context stamped onto every line: the bot identity + chain, plus whichever - // RAILWAY_* identity vars this deployment exposes. const logger = createLogger(config.logLevel, { context: { bot: 'vault-v2-reallocation', chainId: config.chainId, ...railwayContext() } }) - const client = createDeploylessClient(config) + // The fetch fans out per-market and per-cap-id reads, so the read transport batches them into a + // few JSON-RPC round trips. + const client = createDeploylessClient({ + chain: config.chain, + rpcUrl: config.rpcUrl, + rpcUrlFallback: config.rpcUrlFallback, + batch: true + }) - // Startup vault validation, which also gathers the adapter set the signing policy binds to: - // fetchVaultV2Data proves each whitelisted address is a factory-made VaultV2 with exactly one - // MorphoMarketV1 adapter (typed InvalidVaultError otherwise — fatal, since the policy authorizes - // the address as a tx target). isAdapter is a cheap cross-check that the vault recognizes it. + // 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: Record = {} - for (const vault of config.vaultWhitelist) { - await assertContractDeployed(client, vault, 'VAULT_WHITELIST entry') - const vaultData = await fetchVaultV2Data(client, vault, { - chainId: config.chainId, - blockNumber: startupBlock - }) - const recognized = await readContract(client, { + const isAllocator = (vault: Address) => + readContract(client, { address: vault, abi: vaultV2Abi, - functionName: 'isAdapter', - args: [vaultData.adapterAddress] + functionName: 'isAllocator', + args: [eoa] }) - if (!recognized) { - throw new InvalidVaultError( - `vault ${vault} does not recognize adapter ${vaultData.adapterAddress}` - ) - } - adapterByVault[vault] = [vaultData.adapterAddress] - } - // Signed-send path: a plain wallet client + local nonce cursor (separate from the read 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 }), + isAdapter: (vault, adapter) => + readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'isAdapter', + args: [adapter] + }), + isAllocator + }, + 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`). @@ -79,7 +89,7 @@ async function main() { privateKey: config.reallocatorPrivateKey, policy: { chainId: config.chainId, - executor: config.vaultWhitelist, + targets: config.vaultWhitelist, maxFeePerGasWei: config.maxFeeWei, maxGasLimit: DEFAULT_MAX_GAS_LIMIT, maxDataBytes: DEFAULT_MAX_DATA_BYTES, @@ -94,7 +104,6 @@ async function main() { }, logger }) - const eoa = signer.account.address logger.info('startup', { chainId: config.chainId, @@ -106,30 +115,6 @@ async function main() { dryRun: config.dryRun }) - // The allocator role is probed non-fatally: a pending grant must not crash-loop the bot; the tick - // re-checks and resumes on its own. Unlike MetaMorpho V1's onlyAllocatorRole, VaultV2.allocate - // requires isAllocator[msg.sender] strictly — curator/owner do NOT implicitly qualify, so the - // check is deliberately narrower than the V1 bot's. - const isAllocator = (vault: Address) => - readContract(client, { - address: vault, - abi: vaultV2Abi, - functionName: 'isAllocator', - args: [eoa] - }) - for (const vault of config.vaultWhitelist) { - const role = await tryCatch(isAllocator(vault)) - if (role.error || !role.data) { - logger.warn('allocator.missing_role', { - vault, - detail: 'grant the allocator role to the EOA' - }) - } - } - - // Transaction-queue state is in-memory only — chain truth wins on restart. A redeploy re-derives - // the nonce cursor from `getTransactionCount('pending')`, and any tx that was in flight settles - // on-chain regardless of the bot; settlement audit ships via the structured `tx.*` log events. const queue = createPendingQueue({ send: signer.send, getReceipt: signer.getReceipt, @@ -154,10 +139,9 @@ async function main() { // 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. - let lastRunMs = 0 + const intervalGate = createIntervalGate(config.reallocationIntervalMs) const tick = async (chainHead: bigint) => { - if (Date.now() - lastRunMs < config.reallocationIntervalMs) return - lastRunMs = Date.now() + if (!intervalGate()) return await runTick({ vaults: config.vaultWhitelist, chainHead, @@ -173,7 +157,7 @@ async function main() { simulate: (vault, data) => simulateCall(client, { eoa, to: vault, data }), submit: async ({ vault, data, blockNumber }) => { const fees = initialFees(await signer.getBaseFee(), config.maxFeeWei) - await queue.submit({ + return queue.submit({ request: { to: vault, data }, label: vault, maxFeePerGas: fees.maxFeePerGas, 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/math.ts b/bots/vault-v2-reallocation/src/math.ts index 93c7a31e..ed621076 100644 --- a/bots/vault-v2-reallocation/src/math.ts +++ b/bots/vault-v2-reallocation/src/math.ts @@ -1,15 +1,25 @@ import type { Address } from 'viem' -import { AdaptiveCurveIrmLib, MathLib } from '@morpho-org/blue-sdk' -import { getAddress, parseUnits } 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 SECONDS_PER_YEAR = 60n * 60n * 24n * 365n +const WAD_PER_BIP_SCALE = 1_000_000_000n -/** Converts a human percentage (e.g. `4.25`) to its WAD-scaled fraction. */ -export const percentToWad = (percent: number): bigint => parseUnits(percent.toString(), 16) +/** + * 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) +/** 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 @@ -63,11 +73,12 @@ export const getCapHeadroom = ( totalAssets: bigint, capBufferWad: bigint ): bigint => { - const bufferedAbsolute = MathLib.wMulDown(cap.absolute, capBufferWad) - const absoluteHeadroom = bufferedAbsolute > basis ? bufferedAbsolute - basis : 0n + const absoluteHeadroom = MathLib.zeroFloorSub(MathLib.wMulDown(cap.absolute, capBufferWad), basis) if (cap.relative === MathLib.WAD) return absoluteHeadroom - const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), capBufferWad) - const relativeHeadroom = bufferedRelative > basis ? bufferedRelative - basis : 0n + const relativeHeadroom = MathLib.zeroFloorSub( + MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), capBufferWad), + basis + ) return MathLib.min(absoluteHeadroom, relativeHeadroom) } diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts index d508481f..232b3860 100644 --- a/bots/vault-v2-reallocation/src/runner/tick.ts +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -9,7 +9,11 @@ import type { VaultV2Data } from '../vault-data' export type TickDeps = { vaults: Address[] chainHead: bigint - /** Live allocator-role check; a vault without the role is skipped (and resumes once granted). */ + /** + * Strict `isAllocator(eoa)` on the vault — VaultV2.allocate admits no curator/owner fallback. + * Run concurrently with the fetch; a vault the EOA cannot reallocate is skipped, and resumes on + * its own once the role is granted. + */ isAllocator: (vault: Address) => Promise /** * The adapter the signing policy was pinned to at startup. A curator swapping the adapter @@ -21,7 +25,8 @@ export type TickDeps = { strategy: Strategy encodeReallocation: (vaultData: VaultV2Data, reallocation: Reallocation) => Hex simulate: (vault: Address, data: Hex) => Promise - submit: (params: { vault: Address; data: Hex; blockNumber: bigint }) => 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. */ @@ -30,8 +35,7 @@ export type TickDeps = { logger: Logger } -type TickCounters = { - vaults: number +type VaultCounters = { skipped_inflight: number missing_role: number adapter_changed: number @@ -42,6 +46,19 @@ type TickCounters = { 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 summarize = (reallocation: Reallocation) => [ ...reallocation.deallocations.map(leg => ({ action: 'deallocate', @@ -59,101 +76,100 @@ const summarize = (reallocation: Reallocation) => [ })) ] -const processVault = async ( - deps: TickDeps, - vault: Address, - counters: TickCounters -): Promise => { - if (!(await deps.isAllocator(vault))) { - counters.missing_role++ +const processVault = async (deps: TickDeps, vault: Address): Promise => { + const [vaultData, isAllocator] = await Promise.all([ + deps.fetchVault(vault, deps.chainHead), + deps.isAllocator(vault) + ]) + + if (!isAllocator) { deps.logger.warn('allocator.missing_role', { vault }) - return + return { ...NO_COUNTS, missing_role: 1 } } - const vaultData = await deps.fetchVault(vault, deps.chainHead) - const expectedAdapter = deps.expectedAdapter(vault) if (expectedAdapter !== undefined && vaultData.adapterAddress !== expectedAdapter) { - counters.adapter_changed++ 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 + 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). - const foreignIrmMarkets = vaultData.marketsData - .filter(marketData => !marketData.isAdaptiveCurve) - .map(marketData => marketData.id) - if (foreignIrmMarkets.length > 0) { - deps.logger.debug('market.non_adaptive_curve', { vault, markets: foreignIrmMarkets }) + if (vaultData.nonAdaptiveCurveMarketIds.length > 0) { + deps.logger.debug('market.non_adaptive_curve', { + vault, + markets: vaultData.nonAdaptiveCurveMarketIds + }) } const reallocation = deps.strategy(vaultData) - if (!reallocation) return - counters.reallocations_found++ + if (!reallocation) return NO_COUNTS const legs = reallocation.deallocations.length + reallocation.allocations.length - const summary = summarize(reallocation) - deps.logger.info('reallocation.found', { vault, legs, allocations: summary }) + deps.logger.info('reallocation.found', { vault, legs, allocations: summarize(reallocation) }) const data = deps.encodeReallocation(vaultData, reallocation) const sim = await deps.simulate(vault, data) if (sim.status === 'revert') { - counters.sim_reverts++ deps.logger.warn('reallocation.sim_revert', { vault, reason: sim.reason }) - return + return { ...NO_COUNTS, reallocations_found: 1, sim_reverts: 1 } } if (deps.dryRun) { - counters.dry_runs++ + // The plan itself was just logged by reallocation.found — this line only marks the decision. deps.logger.info('reallocation.dry_run', { vault }) - return + return { ...NO_COUNTS, reallocations_found: 1, dry_runs: 1 } } - await deps.submit({ vault, data, blockNumber: deps.chainHead }) - counters.submitted++ + 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: for each whitelisted vault — skip if a tx is in flight, skip (loudly) if - * the allocator role is missing, then fetch a block-pinned snapshot, run the strategy, simulate the + * One reallocation pass: every whitelisted vault is processed concurrently — skip if a tx is in + * flight, fetch a block-pinned snapshot alongside the `isAllocator` read, 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; one wide `tick.end` counters line closes the pass. + * `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 counters: TickCounters = { - vaults: deps.vaults.length, - skipped_inflight: 0, - missing_role: 0, - adapter_changed: 0, - reallocations_found: 0, - sim_reverts: 0, - dry_runs: 0, - submitted: 0, - errors: 0 - } + const inflight = deps.inflightLabels() + + const settled = await Promise.allSettled( + 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 + }) + ) - for (const vault of deps.vaults) { - if (deps.inflightLabels().has(vault)) { - counters.skipped_inflight++ - deps.logger.debug('vault.inflight', { vault }) - continue - } - const { error } = await tryCatch(processVault(deps, vault, counters)) - if (error) { - counters.errors++ - deps.logger.error('vault.error', { vault, reason: deps.revertReason(error) }) - } - } + const counters = settled.reduce( + (acc, result) => { + if (result.status === 'rejected') return { ...acc, errors: acc.errors + 1 } + for (const key of COUNTER_KEYS) acc[key] += result.value[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/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index 1c853dab..2bb2893a 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -2,7 +2,14 @@ import type { Address, Hex } from 'viem' import type { Strategy } from './strategy' -import { apyToRate, getUtilization, rateToApy, rateToUtilization, utilizationToRate } from '../math' +import { + apyToRate, + getUtilization, + rateToApy, + rateToUtilization, + utilizationToRate, + wadToBips +} from '../math' import { createReconciler } from './reconcile' export type ApyRangeConfig = { @@ -18,11 +25,10 @@ export type ApyRangeConfig = { const apyDeltaBips = (from: bigint, to: bigint, rateAtTarget: bigint): number => Math.abs( - Number( - (rateToApy(utilizationToRate(to, rateAtTarget)) - - rateToApy(utilizationToRate(from, rateAtTarget))) / - 1_000_000_000n - ) / 1e5 + wadToBips( + rateToApy(utilizationToRate(to, rateAtTarget)) - + rateToApy(utilizationToRate(from, rateAtTarget)) + ) ) /** diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts index e799301f..0457515b 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -6,7 +6,7 @@ import { isAddressEqual, zeroAddress } from 'viem' import type { VaultV2MarketData } from '../vault-data' import type { Strategy } from './strategy' -import { getUtilization } from '../math' +import { getUtilization, wadToBips } from '../math' import { createReconciler } from './reconcile' type EqualizeUtilizationsConfig = { @@ -54,9 +54,8 @@ export const createEqualizeUtilizationsStrategy = (config: EqualizeUtilizationsC return { targetUtilization, clearsMinDelta: - Math.abs( - Number((getUtilization(marketData.state) - targetUtilization) / 1_000_000_000n) / 1e5 - ) > minUtilizationDeltaBips + Math.abs(wadToBips(getUtilization(marketData.state) - targetUtilization)) > + minUtilizationDeltaBips } } } diff --git a/bots/vault-v2-reallocation/src/strategies/index.ts b/bots/vault-v2-reallocation/src/strategies/index.ts index f586f032..89a39d0f 100644 --- a/bots/vault-v2-reallocation/src/strategies/index.ts +++ b/bots/vault-v2-reallocation/src/strategies/index.ts @@ -1,10 +1,9 @@ -import { assertNever } from '@repo/utils' +import { assertNever, wholePercentToWAD } from '@repo/utils' import type { Config } from '../config' import type { Strategy } from './strategy' -import { CAP_BUFFER_PERCENT } from '../config' -import { percentToWad } from '../math' +import { CAP_BUFFER_WAD } from '../math' import { resolveApyRange, resolveMinApyDeltaBips, @@ -24,17 +23,17 @@ export const createStrategy = (config: Config): Strategy => { case 'apy-range': return createApyRangeStrategy({ allowIdleReallocation: config.allowIdleReallocation, - capBufferWad: percentToWad(CAP_BUFFER_PERCENT), + capBufferWad: CAP_BUFFER_WAD, apyRange: (vault, marketId) => { const range = resolveApyRange(config.chainId, vault, marketId) - return { min: percentToWad(range.min), max: percentToWad(range.max) } + 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: percentToWad(CAP_BUFFER_PERCENT), + capBufferWad: CAP_BUFFER_WAD, minUtilizationDeltaBips: vault => resolveMinUtilizationDeltaBips(config.chainId, vault, config.minUtilizationDeltaBips) }) diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts index d76a7cd7..c3b09618 100644 --- a/bots/vault-v2-reallocation/src/strategies/reconcile.ts +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -1,4 +1,5 @@ import { MathLib } from '@morpho-org/blue-sdk' +import { wholePercentToWAD } from '@repo/utils' import type { VaultV2Data, VaultV2MarketData } from '../vault-data' import type { Reallocation, ReallocationAction, Strategy } from './strategy' @@ -9,7 +10,6 @@ import { getDepositableAmount, getUtilization, getWithdrawableAmount, - percentToWad, takeFromPools } from '../math' @@ -21,7 +21,7 @@ import { * bips left behind scale with the market's borrow, which is what accrues; deliberately looser than * the 99.99% cap buffer, whose sliver absorbs supply-side drift instead. */ -const MAX_TARGET_UTILIZATION = percentToWad(99.9) +const MAX_TARGET_UTILIZATION = wholePercentToWAD(99.9) /** Where one market should sit, and whether getting it there is worth a transaction. */ export type MarketTarget = { 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..1b76550c --- /dev/null +++ b/bots/vault-v2-reallocation/src/vault-checks.ts @@ -0,0 +1,57 @@ +import type { Logger } from '@repo/bot-kit' +import type { Address } from 'viem' + +import { tryCatch } from '@repo/utils' + +import type { VaultV2Data } from './vault-data' + +import { InvalidVaultError } from './invalid-vault.error' + +export type VaultCheckReads = { + /** Fatal liveness gate; throws when the address holds no code on this chain. */ + assertDeployed: (vault: Address) => Promise + /** + * Block-pinned V2 fetch; throws {@link InvalidVaultError} (or the SDK's factory rejection) when + * the address is not a factory-made VaultV2 with exactly one Morpho Blue market adapter. + */ + fetchVault: (vault: Address) => Promise + /** `vault.isAdapter(adapter)` cross-check that the vault recognizes its fetched adapter. */ + isAdapter: (vault: Address, adapter: Address) => Promise + /** Strict `isAllocator(eoa)` — VaultV2.allocate admits no curator/owner fallback. */ + isAllocator: (vault: Address) => Promise +} + +/** + * Startup validation of the whitelist: each vault must hold code, resolve as a factory-made VaultV2 + * with exactly one Morpho Blue market adapter, and recognize that adapter — the signing policy + * authorizes every whitelisted address as a tx target and pins its adapter, so any mismatch throws + * {@link 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 adapterByVault: Record = {} + for (const vault of vaults) { + await reads.assertDeployed(vault) + const vaultData = await reads.fetchVault(vault) + const recognized = await reads.isAdapter(vault, vaultData.adapterAddress) + if (!recognized) { + throw new InvalidVaultError( + `vault ${vault} does not recognize adapter ${vaultData.adapterAddress}` + ) + } + adapterByVault[vault] = [vaultData.adapterAddress] + const role = await tryCatch(reads.isAllocator(vault)) + if (role.error || !role.data) { + logger.warn('allocator.missing_role', { + vault, + detail: 'grant the allocator role to the EOA' + }) + } + } + return adapterByVault +} diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts index 3abe78c7..5b3cde63 100644 --- a/bots/vault-v2-reallocation/src/vault-data.ts +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -47,6 +47,8 @@ export type VaultV2MarketData = { * {@link isAdaptiveCurveMarket}. */ isAdaptiveCurve: boolean + /** A zero-collateral Blue market never borrows, so no rate strategy applies to it. */ + isIdle: boolean } export type VaultV2Data = { @@ -61,6 +63,11 @@ export type VaultV2Data = { /** 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[] } /** @@ -80,6 +87,7 @@ type AdapterMarket = { state: MarketState vaultAssets: bigint rateAtTarget: bigint + isIdle: boolean } // Both Morpho Blue market adapter generations take the same abi-encoded market params in @@ -97,7 +105,8 @@ const normalizeAdapterMarkets = (adapter: MarketAdapter, timestamp: bigint): Ada params, state: accrued.market, vaultAssets: accrued.supplyAssets, - rateAtTarget: accrued.market.rateAtTarget ?? 0n + rateAtTarget: accrued.market.rateAtTarget ?? 0n, + isIdle: accrued.market.isIdle } }) } @@ -112,7 +121,8 @@ const normalizeAdapterMarkets = (adapter: MarketAdapter, timestamp: bigint): Ada accrued.totalSupplyShares, 'Down' ), - rateAtTarget: accrued.rateAtTarget ?? 0n + rateAtTarget: accrued.rateAtTarget ?? 0n, + isIdle: accrued.isIdle } }) } @@ -207,7 +217,7 @@ export const fetchVaultV2Data = async ( const marketCaps = caps.slice(1 + collateralTokens.length) const marketsData = adapterMarkets.map( - ({ params, state, vaultAssets, rateAtTarget }, i): VaultV2MarketData => ({ + ({ params, state, vaultAssets, rateAtTarget, isIdle }, i): VaultV2MarketData => ({ id: params.id, capId: marketCapIds[i]!, params: { @@ -224,7 +234,8 @@ export const fetchVaultV2Data = async ( cap: marketCaps[i]!, vaultAssets, rateAtTarget, - isAdaptiveCurve: isAdaptiveCurveMarket(params.irm, rateAtTarget, chainId) + isAdaptiveCurve: isAdaptiveCurveMarket(params.irm, rateAtTarget, chainId), + isIdle }) ) @@ -235,6 +246,9 @@ export const fetchVaultV2Data = async ( idleAssets: vaultV2.assetBalance, adapterCap, collateralCaps, - marketsData + marketsData, + nonAdaptiveCurveMarketIds: marketsData + .filter(marketData => !marketData.isAdaptiveCurve && !marketData.isIdle) + .map(marketData => marketData.id) } } diff --git a/bots/vault-v2-reallocation/test/config.test.ts b/bots/vault-v2-reallocation/test/config.test.ts index 67cc1510..1c80e633 100644 --- a/bots/vault-v2-reallocation/test/config.test.ts +++ b/bots/vault-v2-reallocation/test/config.test.ts @@ -21,6 +21,11 @@ describe('loadConfig', () => { 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) diff --git a/bots/vault-v2-reallocation/test/encode.test.ts b/bots/vault-v2-reallocation/test/encode.test.ts index 39f6ca7d..9ba87368 100644 --- a/bots/vault-v2-reallocation/test/encode.test.ts +++ b/bots/vault-v2-reallocation/test/encode.test.ts @@ -1,16 +1,12 @@ import { marketParamsAbi } from '@morpho-org/blue-sdk' import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' import { decodeAbiParameters, decodeFunctionData, getAddress, parseUnits } from 'viem' -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { encodeReallocation } from '../src/encode' -import { ADAPTER, makeMarketParams, resetMarketCounter } from './strategies/helpers' +import { ADAPTER, makeMarketParams } from './strategies/helpers' describe('encodeReallocation', () => { - beforeEach(() => { - resetMarketCounter() - }) - it('encodes deallocate legs strictly before allocate legs with exact args', () => { const hotParams = makeMarketParams() const coldParams = makeMarketParams() 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 index 0a095b9c..bb2b0723 100644 --- a/bots/vault-v2-reallocation/test/math.test.ts +++ b/bots/vault-v2-reallocation/test/math.test.ts @@ -1,3 +1,4 @@ +import { wholePercentToWAD } from '@repo/utils' import { getAddress, parseUnits } from 'viem' import { describe, expect, it } from 'vitest' @@ -8,7 +9,6 @@ import { getDepositableAmount, getUtilization, getWithdrawableAmount, - percentToWad, takeFromPools } from '../src/math' import { makeMarket, makeVaultData, RATE_AT_TARGET } from './strategies/helpers' @@ -43,7 +43,9 @@ describe('getCapHeadroom', () => { allocation: parseUnits('10000', 6) } // Buffered absolute (99.99%) is below the basis → zero headroom. - expect(getCapHeadroom(cap, parseUnits('10000', 6), totalAssets, percentToWad(99.99))).toBe(0n) + 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', () => { @@ -204,10 +206,3 @@ describe('deposit pools', () => { expect(takeFromPools(pools, getAddress(`0x${'77'.repeat(20)}`), parseUnits('1', 6))).toBe(0n) }) }) - -describe('percentToWad', () => { - it('scales percentages to WAD fractions', () => { - expect(percentToWad(100)).toBe(WAD) - expect(percentToWad(99.99)).toBe(parseUnits('0.9999', 18)) - }) -}) diff --git a/bots/vault-v2-reallocation/test/runner/tick.test.ts b/bots/vault-v2-reallocation/test/runner/tick.test.ts index a99abf2e..310cf1bd 100644 --- a/bots/vault-v2-reallocation/test/runner/tick.test.ts +++ b/bots/vault-v2-reallocation/test/runner/tick.test.ts @@ -49,7 +49,7 @@ const makeDeps = (overrides: Partial = {}) => { strategy: vi.fn(() => undefined), encodeReallocation: vi.fn(() => DATA), simulate: vi.fn(async () => ({ status: 'ok' as const })), - submit: vi.fn(async () => undefined), + submit: vi.fn(async () => true), dryRun: false, inflightLabels: () => new Set(), revertReason: error => (error instanceof Error ? error.message : String(error)), @@ -71,6 +71,16 @@ describe('runTick', () => { 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) @@ -129,10 +139,11 @@ describe('runTick', () => { expect(tickEnd(events)).toMatchObject({ adapter_changed: 1, submitted: 0 }) }) - it('skips fetch/strategy/simulate while the allocator role is missing', async () => { + it('skips strategy/simulate while the allocator role is missing', async () => { + // The fetch runs concurrently with the role read, so it is issued regardless. const { deps, events } = makeDeps({ isAllocator: vi.fn(async () => false) }) await runTick(deps) - expect(deps.fetchVault).not.toHaveBeenCalled() + expect(deps.strategy).not.toHaveBeenCalled() expect(events).toContainEqual({ level: 'warn', event: 'allocator.missing_role', diff --git a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts index 8c0b0e52..f70fef9b 100644 --- a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -1,13 +1,14 @@ import type { Hex } from 'viem' +import { wholePercentToWAD } from '@repo/utils' import { parseUnits } from 'viem' -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { ApyRangeConfig } from '../../src/strategies/apy-range' -import { apyToRate, percentToWad, rateToUtilization } from '../../src/math' +import { apyToRate, rateToUtilization } from '../../src/math' import { createApyRangeStrategy } from '../../src/strategies/apy-range' -import { makeMarket, makeVaultData, RATE_AT_TARGET, resetMarketCounter } from './helpers' +import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' type ApyRangePercent = { min: number; max: number } @@ -22,10 +23,10 @@ const makeStrategy = ( const defaultApyRange = overrides.defaultApyRange ?? { min: 2, max: 8 } const config: ApyRangeConfig = { allowIdleReallocation: overrides.allowIdleReallocation ?? true, - capBufferWad: percentToWad(99.99), + capBufferWad: wholePercentToWAD(99.99), apyRange: (_vault, marketId) => { const range = overrides.marketApyRanges?.[marketId] ?? defaultApyRange - return { min: percentToWad(range.min), max: percentToWad(range.max) } + return { min: wholePercentToWAD(range.min), max: wholePercentToWAD(range.max) } }, // No min delta threshold by default so tests are predictable. minApyDeltaBips: () => overrides.minApyDeltaBips ?? 0 @@ -35,13 +36,9 @@ const makeStrategy = ( /** The utilization at which the market yields the given borrow APY. */ const apyToUtilization = (apyPercent: number, rateAtTarget: bigint): bigint => - rateToUtilization(apyToRate(percentToWad(apyPercent)), rateAtTarget) + rateToUtilization(apyToRate(wholePercentToWAD(apyPercent)), rateAtTarget) describe('createApyRangeStrategy', () => { - beforeEach(() => { - resetMarketCounter() - }) - describe('no reallocation needed', () => { it('returns undefined when all markets are within APY range', () => { const strategy = makeStrategy() diff --git a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts index 8708f765..ffe41160 100644 --- a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -1,27 +1,19 @@ +import { wholePercentToWAD } from '@repo/utils' import { getAddress, parseUnits } from 'viem' -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' -import { percentToWad } from '../../src/math' import { createEqualizeUtilizationsStrategy } from '../../src/strategies/equalize-utilizations' -import { - makeMarket, - makeMarketParams, - makeVaultData, - RATE_AT_TARGET, - resetMarketCounter, - VAULT -} from './helpers' +import { makeMarket, makeMarketParams, makeVaultData, RATE_AT_TARGET, VAULT } from './helpers' const makeStrategy = (minUtilizationDeltaBips: (vault: `0x${string}`) => number = () => 0) => - createEqualizeUtilizationsStrategy({ capBufferWad: percentToWad(99.99), minUtilizationDeltaBips }) + createEqualizeUtilizationsStrategy({ + capBufferWad: wholePercentToWAD(99.99), + minUtilizationDeltaBips + }) const WAD = 10n ** 18n describe('createEqualizeUtilizationsStrategy', () => { - beforeEach(() => { - resetMarketCounter() - }) - it('deallocates from below-average and allocates into above-average markets (deltas)', () => { const strategy = makeStrategy() const hotMarket = makeMarket({ diff --git a/bots/vault-v2-reallocation/test/strategies/helpers.ts b/bots/vault-v2-reallocation/test/strategies/helpers.ts index ddead302..34be3349 100644 --- a/bots/vault-v2-reallocation/test/strategies/helpers.ts +++ b/bots/vault-v2-reallocation/test/strategies/helpers.ts @@ -22,12 +22,10 @@ const UNLIMITED_CAP: CapState = { 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 resetMarketCounter = () => { - marketCounter = 0 -} - export const makeMarketParams = (overrides?: Partial): InputMarketParams => { marketCounter++ return { @@ -47,9 +45,10 @@ export const makeMarket = (opts: { utilization: bigint vaultAssets: bigint cap?: Partial - rateAtTarget: bigint + rateAtTarget?: bigint params?: InputMarketParams isAdaptiveCurve?: boolean + isIdle?: boolean supplyAssets?: bigint }): VaultV2MarketData => { const totalSupplyAssets = opts.supplyAssets ?? parseUnits('100000', 6) @@ -63,8 +62,9 @@ export const makeMarket = (opts: { // divergence override `cap.allocation` explicitly. cap: { ...UNLIMITED_CAP, allocation: opts.vaultAssets, ...opts.cap }, vaultAssets: opts.vaultAssets, - rateAtTarget: opts.rateAtTarget, - isAdaptiveCurve: opts.isAdaptiveCurve ?? true + rateAtTarget: opts.rateAtTarget ?? RATE_AT_TARGET, + isAdaptiveCurve: opts.isAdaptiveCurve ?? true, + isIdle: opts.isIdle ?? false } } @@ -81,5 +81,6 @@ export const makeVaultData = ( 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/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts index 62d09365..3f6ae964 100644 --- a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -1,11 +1,11 @@ +import { wholePercentToWAD } from '@repo/utils' import { parseUnits } from 'viem' -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { Classify } from '../../src/strategies/reconcile' -import { percentToWad } from '../../src/math' import { createReconciler } from '../../src/strategies/reconcile' -import { makeMarket, makeVaultData, RATE_AT_TARGET, resetMarketCounter } from './helpers' +import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' const WAD = 10n ** 18n @@ -14,7 +14,7 @@ const makeReconciler = ( overrides: Partial<{ allowIdleParking: boolean; capBufferWad: bigint }> = {} ) => createReconciler({ - capBufferWad: overrides.capBufferWad ?? percentToWad(99.99), + capBufferWad: overrides.capBufferWad ?? wholePercentToWAD(99.99), allowIdleParking: overrides.allowIdleParking ?? true, classifierFor: () => classify }) @@ -23,10 +23,6 @@ const makeReconciler = ( const toHalf: Classify = () => ({ targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true }) describe('createReconciler', () => { - beforeEach(() => { - resetMarketCounter() - }) - 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 diff --git a/bots/vault-v2-reallocation/test/strategy-config.test.ts b/bots/vault-v2-reallocation/test/strategy-config.test.ts index 476cf800..d5a39d61 100644 --- a/bots/vault-v2-reallocation/test/strategy-config.test.ts +++ b/bots/vault-v2-reallocation/test/strategy-config.test.ts @@ -72,3 +72,15 @@ describe('resolveMinUtilizationDeltaBips', () => { expect(resolveMinUtilizationDeltaBips(CHAIN_ID, VAULT, 250)).toBe(100) }) }) + +describe('table shape', () => { + it('every configured APY range has min < max', () => { + for (const table of [vaultApyRanges, marketApyRanges]) { + for (const perChain of Object.values(table)) { + for (const range of Object.values(perChain)) { + expect(range.min).toBeLessThan(range.max) + } + } + } + }) +}) 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..eeb32bba --- /dev/null +++ b/bots/vault-v2-reallocation/test/vault-checks.test.ts @@ -0,0 +1,77 @@ +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 './strategies/helpers' + +function 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()), + isAdapter: vi.fn(async () => true), + isAllocator: vi.fn(async () => true), + ...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('throws when the vault does not recognize its fetched adapter', async () => { + const { logger } = spyLogger() + await expect( + checkVaults([VAULT], makeReads({ isAdapter: vi.fn(async () => false) }), logger) + ).rejects.toBeInstanceOf(InvalidVaultError) + }) + + 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 or unreadable', async () => { + const { logger, events } = spyLogger() + await checkVaults([VAULT], makeReads({ isAllocator: vi.fn(async () => false) }), logger) + expect(events.some(e => e.event === 'allocator.missing_role' && e.level === 'warn')).toBe(true) + + const { logger: logger2, events: events2 } = spyLogger() + await checkVaults( + [VAULT], + makeReads({ isAllocator: vi.fn(async () => Promise.reject(new Error('rpc'))) }), + logger2 + ) + expect(events2.some(e => e.event === 'allocator.missing_role')).toBe(true) + }) +}) diff --git a/packages/bot-kit/test/policy.test.ts b/packages/bot-kit/test/policy.test.ts index 66aa8917..201f449b 100644 --- a/packages/bot-kit/test/policy.test.ts +++ b/packages/bot-kit/test/policy.test.ts @@ -105,7 +105,7 @@ describe('evaluatePolicy multicall envelope', () => { const MULTICALL_POLICY: Policy = { chainId: 8453, - executor: [VAULT_A, VAULT_B], + targets: [VAULT_A, VAULT_B], maxFeePerGasWei: 300_000_000_000n, maxGasLimit: 15_000_000n, maxDataBytes: 65_536, @@ -158,7 +158,7 @@ describe('evaluatePolicy multicall envelope', () => { it('rejects an outer target with no registered inner targets', () => { const policy: Policy = { ...MULTICALL_POLICY, - executor: getAddress(`0x${'cc'.repeat(20)}`) + targets: [getAddress(`0x${'cc'.repeat(20)}`)] } expect(evaluatePolicy(policy, mtx({ to: getAddress(`0x${'cc'.repeat(20)}`) }))).toMatchObject({ ok: false, From 1a7c1c821cf733e8de97fa7f0a2ee6e89a05a083 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Wed, 19 Aug 2026 13:39:53 -0500 Subject: [PATCH 12/14] fix(vault-v2-reallocation): clamp target sizes without flipping leg sides Approved clamp-corner design: a leg's side comes from the classifier's raw intent while its size comes from the MAX_TARGET_UTILIZATION-clamped target; a move the clamp leaves empty or backwards is dropped, never inverted, and both strategies' min-delta gates measure against the effective (clamped) bound. Adds regression tests (mutation-verified) and refreshes the README (target ceiling, not_broadcast event, concurrent role/fetch wording). Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/README.md | 18 +++- bots/vault-v2-reallocation/src/index.ts | 4 +- bots/vault-v2-reallocation/src/runner/tick.ts | 3 +- .../src/strategies/apy-range.ts | 7 +- .../src/strategies/equalize-utilizations.ts | 11 ++- .../src/strategies/reconcile.ts | 25 +++-- .../test/strategies/apy-range.test.ts | 93 ++++++++++++++++++- .../strategies/equalize-utilizations.test.ts | 32 +++++++ .../test/strategies/reconcile.test.ts | 30 ++++++ 9 files changed, 197 insertions(+), 26 deletions(-) diff --git a/bots/vault-v2-reallocation/README.md b/bots/vault-v2-reallocation/README.md index 6197b604..6f9cdb94 100644 --- a/bots/vault-v2-reallocation/README.md +++ b/bots/vault-v2-reallocation/README.md @@ -17,13 +17,13 @@ One long-running process per chain. A block watcher drives per-block queue maint 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. Re-check the EOA's allocator role (`allocator.missing_role` + skip while absent — a pending - grant never crash-loops the bot, and a fresh grant is picked up without restart). -3. Fetch a block-pinned RPC snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` +2. Concurrently re-check the EOA's allocator role (`allocator.missing_role` + skip while absent — + a pending grant never crash-loops the bot, and a fresh grant is picked up without restart) and + fetch a block-pinned RPC snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` (which also proves the address is a factory-made VaultV2), plus per-id `absoluteCap`/`relativeCap`/`allocation` reads for every market id, the adapter id, and each collateral id. No Morpho API dependency. -4. Run the strategy — a pure function of that snapshot, emitting **exact-amount deltas** +3. 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 @@ -35,7 +35,7 @@ default 10 min) throttles the actual reallocation passes. Each pass, per whiteli - **`apy-range`**: keep each market's borrow APY inside its configured range by inverting the AdaptiveCurveIRM curve; allocations top up from idle (`ALLOW_IDLE_REALLOCATION`). Fires only past `MIN_APY_DELTA_BIPS`. -5. Encode ONE `vault.multicall([deallocate…, allocate…])` (deallocations strictly first so idle is +4. 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`). @@ -47,6 +47,13 @@ as the contract's no-constraint sentinel, and capacity freed by the plan's own d (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 against the clamped target the emitted leg actually realizes. + 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`). @@ -147,6 +154,7 @@ already-provisioned services with `DEPLOY_ONLY=1` on merge to main (staging) and 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. diff --git a/bots/vault-v2-reallocation/src/index.ts b/bots/vault-v2-reallocation/src/index.ts index 1e9a0de9..7a302f3d 100644 --- a/bots/vault-v2-reallocation/src/index.ts +++ b/bots/vault-v2-reallocation/src/index.ts @@ -40,8 +40,8 @@ async function main() { context: { bot: 'vault-v2-reallocation', chainId: config.chainId, ...railwayContext() } }) - // The fetch fans out per-market and per-cap-id reads, so the read transport batches them into a - // few JSON-RPC round trips. + // blue-sdk's vault fetch fans out many single-value reads, so the read transport batches them + // into a few JSON-RPC round trips (the per-cap-id reads are already one multicall). const client = createDeploylessClient({ chain: config.chain, rpcUrl: config.rpcUrl, diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts index 232b3860..b9fb6c91 100644 --- a/bots/vault-v2-reallocation/src/runner/tick.ts +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -10,7 +10,8 @@ export type TickDeps = { vaults: Address[] chainHead: bigint /** - * Strict `isAllocator(eoa)` on the vault — VaultV2.allocate admits no curator/owner fallback. + * Strict `isAllocator(eoa)` on the vault — deliberately narrower than the V1 bot's + * allocator|curator|owner check, because VaultV2.allocate admits no curator/owner fallback. * Run concurrently with the fetch; a vault the EOA cannot reallocate is skipped, and resumes on * its own once the role is granted. */ diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index 2bb2893a..cfef797c 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -10,7 +10,7 @@ import { utilizationToRate, wadToBips } from '../math' -import { createReconciler } from './reconcile' +import { createReconciler, MAX_TARGET_UTILIZATION } from './reconcile' export type ApyRangeConfig = { /** Whether excess deallocations may be parked in the vault's idle balance. */ @@ -59,11 +59,14 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => const bound = utilization > upperBound ? upperBound : utilization < lowerBound ? lowerBound : undefined if (bound === undefined) return undefined + // The reconciler sizes toward at most {@link MAX_TARGET_UTILIZATION}, so the firing gate + // measures the move the emitted leg can actually realize, not the raw inverted bound. + const effectiveBound = bound > MAX_TARGET_UTILIZATION ? MAX_TARGET_UTILIZATION : bound return { targetUtilization: bound, clearsMinDelta: - apyDeltaBips(utilization, bound, rateAtTarget) > + apyDeltaBips(utilization, effectiveBound, 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 index 0457515b..f160b589 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -7,7 +7,7 @@ import type { VaultV2MarketData } from '../vault-data' import type { Strategy } from './strategy' import { getUtilization, wadToBips } from '../math' -import { createReconciler } from './reconcile' +import { createReconciler, MAX_TARGET_UTILIZATION } from './reconcile' type EqualizeUtilizationsConfig = { /** WAD-scaled cap scale factor (e.g. 99.99% as 0.9999e18). */ @@ -16,7 +16,7 @@ type EqualizeUtilizationsConfig = { minUtilizationDeltaBips: (vault: Address) => number } -const { wDivDown } = MathLib +const { min, wDivDown } = MathLib const isRealCollateral = (marketData: VaultV2MarketData): boolean => !isAddressEqual(marketData.params.collateralToken, zeroAddress) @@ -45,16 +45,17 @@ export const createEqualizeUtilizationsStrategy = (config: EqualizeUtilizationsC if (totalSupply === 0n || totalBorrow === 0n) return () => undefined const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) - // May exceed WAD in bad-debt states — the reconciler clamps every target to what a leg can - // realize, so the raw aggregate is reported as-is. + // May exceed WAD in bad-debt states — the raw aggregate carries the side intent, while the + // firing gate measures against the clamped target the emitted leg actually realizes. const targetUtilization = wDivDown(totalBorrow, totalSupply) + const effectiveTarget = min(targetUtilization, MAX_TARGET_UTILIZATION) return marketData => { if (!isRealCollateral(marketData)) return undefined return { targetUtilization, clearsMinDelta: - Math.abs(wadToBips(getUtilization(marketData.state) - targetUtilization)) > + Math.abs(wadToBips(getUtilization(marketData.state) - effectiveTarget)) > minUtilizationDeltaBips } } diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts index c3b09618..7f4d808e 100644 --- a/bots/vault-v2-reallocation/src/strategies/reconcile.ts +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -21,7 +21,7 @@ import { * bips left behind scale with the market's borrow, which is what accrues; deliberately looser than * the 99.99% cap buffer, whose sliver absorbs supply-side drift instead. */ -const MAX_TARGET_UTILIZATION = wholePercentToWAD(99.9) +export const MAX_TARGET_UTILIZATION = wholePercentToWAD(99.9) /** Where one market should sit, and whether getting it there is worth a transaction. */ export type MarketTarget = { @@ -84,15 +84,20 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { const classified = vaultData.marketsData.flatMap(marketData => { const verdict = classify(marketData) if (verdict === undefined) return [] - // Feasibility is the reconciler's job: every target is clamped to what a leg can realize. + const utilization = getUtilization(marketData.state) + // At the target exactly there is nothing to move — skip before sizing. + if (utilization === verdict.targetUtilization) return [] + // Feasibility is the reconciler's job: sizes come from the CLAMPED target, but the side + // comes from the classifier's raw intent — a move left empty or backwards by the clamp + // (intent deallocate, utilization already at/past the ceiling) is dropped, never inverted. + const side = + utilization < verdict.targetUtilization ? ('deallocate' as const) : ('allocate' as const) const target = { ...verdict, targetUtilization: min(verdict.targetUtilization, MAX_TARGET_UTILIZATION) } - const utilization = getUtilization(marketData.state) - // At the target exactly there is nothing to move — skip before sizing. - if (utilization === target.targetUtilization) return [] - return [{ marketData, target, utilization }] + if (side === 'deallocate' && utilization >= target.targetUtilization) return [] + return [{ marketData, side, target, utilization }] }) const moves: SizedMove[] = [] @@ -100,8 +105,8 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { let totalAmountToAllocate = 0n const sizingPools = createDepositPools(vaultData, options.capBufferWad) - for (const { marketData, target, utilization } of classified) { - if (utilization > target.targetUtilization) continue + 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) @@ -114,8 +119,8 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { }) } } - for (const { marketData, target, utilization } of classified) { - if (utilization < target.targetUtilization) continue + for (const { marketData, side, target } of classified) { + if (side !== 'allocate') continue const amount = takeFromPools( sizingPools, marketData.params.collateralToken, diff --git a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts index f70fef9b..eb4cb0b1 100644 --- a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -6,8 +6,15 @@ import { describe, expect, it } from 'vitest' import type { ApyRangeConfig } from '../../src/strategies/apy-range' -import { apyToRate, rateToUtilization } from '../../src/math' +import { + apyToRate, + rateToApy, + rateToUtilization, + utilizationToRate, + wadToBips +} from '../../src/math' import { createApyRangeStrategy } from '../../src/strategies/apy-range' +import { MAX_TARGET_UTILIZATION } from '../../src/strategies/reconcile' import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' type ApyRangePercent = { min: number; max: number } @@ -208,6 +215,90 @@ describe('createApyRangeStrategy', () => { }) 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', () => { diff --git a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts index ffe41160..fc67f9ed 100644 --- a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -103,6 +103,38 @@ describe('createEqualizeUtilizationsStrategy', () => { 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 diff --git a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts index 3f6ae964..b792fe8d 100644 --- a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -53,6 +53,36 @@ describe('createReconciler', () => { 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 + ? { targetUtilization: WAD, clearsMinDelta: true } + : marketData.id === coldMarket.id + ? { targetUtilization: (80n * WAD) / 100n, clearsMinDelta: true } + : { targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true } + 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, From 6220d88db72d3ea080af4e8a551317cade802897 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Wed, 19 Aug 2026 23:11:45 -0500 Subject: [PATCH 13/14] fix(vault-v2-reallocation): adopt classifier intent contract and deploy knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors V1's landed clamp-corner design: MAX_TARGET_UTILIZATION moves to math.ts, classifiers clamp their own target and report the side as MarketTarget.intent (decided on the raw bound), and the reconciler drops — never inverts — a move the clamp leaves empty or backwards, keeping an inert clamp backstop. Min-delta gates measure the clamped move. Also mirrors the deploy surface: chainId-suffixed tuning knobs with set-when-provided / delete-when-absent semantics in deploy-railway.ts and docker-compose.yml, and drops the unused @morpho-org/morpho-ts dependency. The bot-kit submit mutex (3160505) is inherited by the restack; verified the coalescing mutex serializes FIFO without result-sharing and rejects only the throwing caller. Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/docker-compose.yml | 20 +++--- bots/vault-v2-reallocation/package.json | 1 - .../scripts/deploy-railway.ts | 62 +++++++++++++++++-- bots/vault-v2-reallocation/src/math.ts | 14 +++++ .../src/strategies/apy-range.ts | 14 +++-- .../src/strategies/equalize-utilizations.ts | 17 ++--- .../src/strategies/reconcile.ts | 56 +++++++++-------- .../test/strategies/apy-range.test.ts | 2 +- .../test/strategies/reconcile.test.ts | 49 ++++++++------- pnpm-lock.yaml | 3 - 10 files changed, 156 insertions(+), 82 deletions(-) diff --git a/bots/vault-v2-reallocation/docker-compose.yml b/bots/vault-v2-reallocation/docker-compose.yml index f206ea9a..8b984b98 100644 --- a/bots/vault-v2-reallocation/docker-compose.yml +++ b/bots/vault-v2-reallocation/docker-compose.yml @@ -1,7 +1,9 @@ # 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. +# …) and a REALLOCATOR_PRIVATE_KEY holding the allocator role on every whitelisted vault. The tuning +# knobs are chainId-suffixed too (MIN_APY_DELTA_BIPS_1, MAX_FEE_GWEI_8453, …) so chains can differ; +# leave one unset to take the bot's own default. services: bot-mainnet: build: @@ -15,10 +17,10 @@ services: VAULT_WHITELIST: ${VAULT_WHITELIST_1:?set VAULT_WHITELIST_1} STRATEGY: ${STRATEGY_1:-equalize-utilizations} REALLOCATION_INTERVAL_MS: ${REALLOCATION_INTERVAL_MS:-} - MIN_APY_DELTA_BIPS: ${MIN_APY_DELTA_BIPS:-} - MIN_UTILIZATION_DELTA_BIPS: ${MIN_UTILIZATION_DELTA_BIPS:-} - ALLOW_IDLE_REALLOCATION: ${ALLOW_IDLE_REALLOCATION:-} - MAX_FEE_GWEI: ${MAX_FEE_GWEI:-} + 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} @@ -42,10 +44,10 @@ services: VAULT_WHITELIST: ${VAULT_WHITELIST_8453:?set VAULT_WHITELIST_8453} STRATEGY: ${STRATEGY_8453:-equalize-utilizations} REALLOCATION_INTERVAL_MS: ${REALLOCATION_INTERVAL_MS:-} - MIN_APY_DELTA_BIPS: ${MIN_APY_DELTA_BIPS:-} - MIN_UTILIZATION_DELTA_BIPS: ${MIN_UTILIZATION_DELTA_BIPS:-} - ALLOW_IDLE_REALLOCATION: ${ALLOW_IDLE_REALLOCATION:-} - MAX_FEE_GWEI: ${MAX_FEE_GWEI:-} + 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:-} diff --git a/bots/vault-v2-reallocation/package.json b/bots/vault-v2-reallocation/package.json index 8325d17f..b46bf60b 100644 --- a/bots/vault-v2-reallocation/package.json +++ b/bots/vault-v2-reallocation/package.json @@ -14,7 +14,6 @@ "dependencies": { "@morpho-org/blue-sdk": "catalog:", "@morpho-org/blue-sdk-viem": "catalog:", - "@morpho-org/morpho-ts": "catalog:", "@repo/bot-kit": "workspace:*", "@repo/utils": "workspace:*", "viem": "catalog:" diff --git a/bots/vault-v2-reallocation/scripts/deploy-railway.ts b/bots/vault-v2-reallocation/scripts/deploy-railway.ts index 35b2a6bb..100ead19 100644 --- a/bots/vault-v2-reallocation/scripts/deploy-railway.ts +++ b/bots/vault-v2-reallocation/scripts/deploy-railway.ts @@ -5,12 +5,22 @@ * 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_`, + * `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, 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 CI re-ships - * already-provisioned services) no variable is written at all, so CI needs no secret to redeploy. + * 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 CI re-ships already-provisioned services) no variable is written at all, so CI + * needs no secret to redeploy. */ import { delay, tryCatch } from '@repo/utils' import { $ } from 'execa' @@ -125,6 +135,28 @@ const setVar = async (service: string, kv: string): Promise => { 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 => { @@ -182,7 +214,7 @@ const waitForDeploy = async ( } // CRASHED is a failure: the bot fails loud at startup on a bad config (empty whitelist, a -// whitelisted address that isn't a MetaMorpho vault), so a crash-looping service must never read +// 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' @@ -254,7 +286,18 @@ const chainSecrets = CHAINS.map(chain => { reallocatorPrivateKey, strategy, dryRun, - betterstackHeartbeatUrl + 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: { + 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) } }) @@ -277,6 +320,15 @@ for (const chain of chainSecrets) { 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) { diff --git a/bots/vault-v2-reallocation/src/math.ts b/bots/vault-v2-reallocation/src/math.ts index ed621076..d192986c 100644 --- a/bots/vault-v2-reallocation/src/math.ts +++ b/bots/vault-v2-reallocation/src/math.ts @@ -15,6 +15,20 @@ const WAD_PER_BIP_SCALE = 1_000_000_000n */ 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 diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts index cfef797c..587653c4 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -5,12 +5,13 @@ import type { Strategy } from './strategy' import { apyToRate, getUtilization, + MAX_TARGET_UTILIZATION, rateToApy, rateToUtilization, utilizationToRate, wadToBips } from '../math' -import { createReconciler, MAX_TARGET_UTILIZATION } from './reconcile' +import { createReconciler } from './reconcile' export type ApyRangeConfig = { /** Whether excess deallocations may be parked in the vault's idle balance. */ @@ -59,14 +60,15 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => const bound = utilization > upperBound ? upperBound : utilization < lowerBound ? lowerBound : undefined if (bound === undefined) return undefined - // The reconciler sizes toward at most {@link MAX_TARGET_UTILIZATION}, so the firing gate - // measures the move the emitted leg can actually realize, not the raw inverted bound. - const effectiveBound = bound > MAX_TARGET_UTILIZATION ? MAX_TARGET_UTILIZATION : bound + // 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: bound, + targetUtilization, + intent: utilization > bound ? 'allocate' : 'deallocate', clearsMinDelta: - apyDeltaBips(utilization, effectiveBound, rateAtTarget) > + apyDeltaBips(utilization, targetUtilization, 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 index f160b589..b889dfa3 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -6,8 +6,8 @@ import { isAddressEqual, zeroAddress } from 'viem' import type { VaultV2MarketData } from '../vault-data' import type { Strategy } from './strategy' -import { getUtilization, wadToBips } from '../math' -import { createReconciler, MAX_TARGET_UTILIZATION } from './reconcile' +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). */ @@ -45,18 +45,19 @@ export const createEqualizeUtilizationsStrategy = (config: EqualizeUtilizationsC if (totalSupply === 0n || totalBorrow === 0n) return () => undefined const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) - // May exceed WAD in bad-debt states — the raw aggregate carries the side intent, while the - // firing gate measures against the clamped target the emitted leg actually realizes. - const targetUtilization = wDivDown(totalBorrow, totalSupply) - const effectiveTarget = min(targetUtilization, MAX_TARGET_UTILIZATION) + // 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: - Math.abs(wadToBips(getUtilization(marketData.state) - effectiveTarget)) > - minUtilizationDeltaBips + Math.abs(wadToBips(utilization - targetUtilization)) > minUtilizationDeltaBips } } } diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts index 7f4d808e..99709836 100644 --- a/bots/vault-v2-reallocation/src/strategies/reconcile.ts +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -1,5 +1,4 @@ import { MathLib } from '@morpho-org/blue-sdk' -import { wholePercentToWAD } from '@repo/utils' import type { VaultV2Data, VaultV2MarketData } from '../vault-data' import type { Reallocation, ReallocationAction, Strategy } from './strategy' @@ -10,23 +9,24 @@ import { getDepositableAmount, getUtilization, getWithdrawableAmount, + MAX_TARGET_UTILIZATION, takeFromPools } from '../math' -/** - * 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 - * the 99.99% cap buffer, whose sliver absorbs supply-side drift instead. - */ -export const MAX_TARGET_UTILIZATION = wholePercentToWAD(99.9) +/** 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 - /** Whether this market's own move clears the strategy's min-delta threshold. */ + /** + * 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 this market's own move — measured against the CLAMPED target — clears the min-delta. */ clearsMinDelta: boolean } @@ -48,7 +48,7 @@ type ReconcilerOptions = { type SizedMove = { marketData: VaultV2MarketData - side: 'allocate' | 'deallocate' + side: MoveIntent amount: bigint clearsMinDelta: boolean } @@ -76,7 +76,9 @@ const toLeg = ({ marketData }: SizedMove, assets: bigint): ReallocationAction => * 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 clamp. + * 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 => { @@ -84,20 +86,24 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { 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) - // At the target exactly there is nothing to move — skip before sizing. - if (utilization === verdict.targetUtilization) return [] - // Feasibility is the reconciler's job: sizes come from the CLAMPED target, but the side - // comes from the classifier's raw intent — a move left empty or backwards by the clamp - // (intent deallocate, utilization already at/past the ceiling) is dropped, never inverted. - const side = - utilization < verdict.targetUtilization ? ('deallocate' as const) : ('allocate' as const) - const target = { - ...verdict, - targetUtilization: min(verdict.targetUtilization, MAX_TARGET_UTILIZATION) + 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 [] } - if (side === 'deallocate' && utilization >= target.targetUtilization) return [] - return [{ marketData, side, target, utilization }] + return [ + { marketData, side, target: { targetUtilization, clearsMinDelta: verdict.clearsMinDelta } } + ] }) const moves: SizedMove[] = [] diff --git a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts index eb4cb0b1..de22831e 100644 --- a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -8,13 +8,13 @@ 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 { MAX_TARGET_UTILIZATION } from '../../src/strategies/reconcile' import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' type ApyRangePercent = { min: number; max: number } diff --git a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts index b792fe8d..1d7575ad 100644 --- a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -2,8 +2,9 @@ import { wholePercentToWAD } from '@repo/utils' import { parseUnits } from 'viem' import { describe, expect, it } from 'vitest' -import type { Classify } from '../../src/strategies/reconcile' +import type { Classify, MarketTarget } from '../../src/strategies/reconcile' +import { getUtilization } from '../../src/math' import { createReconciler } from '../../src/strategies/reconcile' import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' @@ -19,15 +20,25 @@ const makeReconciler = ( 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 + }) + // Every market converges on 50% utilization and always clears the gate. -const toHalf: Classify = () => ({ targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true }) +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 = () => ({ targetUtilization: WAD, clearsMinDelta: true }) + const toWad: Classify = toTarget(WAD) const coldMarket = makeMarket({ utilization: (50n * WAD) / 100n, vaultAssets: parseUnits('100000', 6), // the adapter holds the whole market @@ -41,9 +52,7 @@ describe('createReconciler', () => { // 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) - : { targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true } + 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) @@ -73,10 +82,10 @@ describe('createReconciler', () => { }) const classify: Classify = marketData => marketData.id === nearFullMarket.id - ? { targetUtilization: WAD, clearsMinDelta: true } + ? toTarget(WAD)(marketData) : marketData.id === coldMarket.id - ? { targetUtilization: (80n * WAD) / 100n, clearsMinDelta: true } - : { targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true } + ? toTarget((80n * WAD) / 100n)(marketData) + : toHalf(marketData) const result = makeReconciler(classify)(makeVaultData([nearFullMarket, coldMarket, hotMarket])) expect(result).toBeDefined() const legs = [...result!.allocations, ...result!.deallocations] @@ -114,9 +123,7 @@ describe('createReconciler', () => { rateAtTarget: RATE_AT_TARGET }) const classify: Classify = marketData => - marketData.id === excluded.id - ? undefined - : { targetUtilization: (50n * WAD) / 100n, clearsMinDelta: true } + marketData.id === excluded.id ? undefined : toHalf(marketData) // The only allocation candidate is excluded → one-sided → no plan. expect(makeReconciler(classify)(makeVaultData([excluded, coldMarket]))).toBeUndefined() }) @@ -163,10 +170,8 @@ describe('createReconciler', () => { vaultAssets: parseUnits('20000', 6), rateAtTarget: RATE_AT_TARGET }) - const classify: Classify = marketData => ({ - targetUtilization: (50n * WAD) / 100n, - clearsMinDelta: marketData.id === gateOnlyMarket.id - }) + const classify: Classify = marketData => + toTarget((50n * WAD) / 100n, marketData.id === gateOnlyMarket.id)(marketData) expect( makeReconciler(classify)(makeVaultData([gateOnlyMarket, hotMarket, coldMarket])) ).toBeUndefined() @@ -192,10 +197,8 @@ describe('createReconciler', () => { supplyAssets: parseUnits('1000', 6), rateAtTarget: RATE_AT_TARGET }) - const classify: Classify = marketData => ({ - targetUtilization: (50n * WAD) / 100n, - clearsMinDelta: marketData.id === hotA.id - }) + const classify: Classify = marketData => + toTarget((50n * WAD) / 100n, marketData.id === hotA.id)(marketData) expect(makeReconciler(classify)(makeVaultData([hotB, hotA, coldMarket]))).toBeUndefined() }) @@ -216,10 +219,8 @@ describe('createReconciler', () => { supplyAssets: parseUnits('1000', 6), rateAtTarget: RATE_AT_TARGET }) - const classify: Classify = marketData => ({ - targetUtilization: (50n * WAD) / 100n, - clearsMinDelta: marketData.id === hotA.id - }) + 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]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccae0b84..aa550bd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,9 +463,6 @@ importers: '@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)) - '@morpho-org/morpho-ts': - specifier: 'catalog:' - version: 2.8.0 '@repo/bot-kit': specifier: workspace:* version: link:../../packages/bot-kit From 959b66384ce45676d92899a81e007839bef0b613 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Wed, 19 Aug 2026 23:58:45 -0500 Subject: [PATCH 14/14] feat(vault-v2-reallocation): deployless lens fetch and realized-delta gate One soltag deployless lens (src/state/lens.sol.ts) replaces the fetchAccrualVaultV2 fan-out, the per-id cap multicall, and the separate isAllocator read: factory identity, allocator bit, idle balance, factory-classified adapter set (both market-adapter generations), and per-market accrued Blue state, position, rateAtTarget, and all three cap-level triples ride ONE eth_call per vault per pass. Morpho.accrueInterest runs inside the simulation and totalAssets() is read after it, so the snapshot is exact on-chain state with no client-side accrual. The HTTP batch option is gone with it. Mirrors V1's realized-delta gate: MarketTarget.clearsMinDelta is now judged on the utilization each TRIMMED leg actually realizes, so a clearing move cut to a fragment by the budget or cap pools cannot arm a plan (corollary: an empty-market exit only ships alongside a leg that clears on its own). Also mirrors REALLOCATION_INTERVAL_MS deploy/compose suffixing and the probe's typed config error. Live evidence: field-by-field lens-vs-SDK equivalence diff at pinned blocks on four real vaults across mainnet + Base (536 fields, 0 mismatches, incl. in-Solidity cap ids vs the SDK derivations), one live InvalidVaultError on a vault-v1-adapter vault, and a mainnet DRY_RUN smoke through the full lens -> policy-pin -> tick path. Known gap: every live vault runs MorphoMarketV1AdapterV2, so the original-generation branch has no live counterpart (documented in the README). Co-Authored-By: Claude Fable 5 --- bots/vault-v2-reallocation/README.md | 49 ++- bots/vault-v2-reallocation/docker-compose.yml | 6 +- bots/vault-v2-reallocation/package.json | 5 +- bots/vault-v2-reallocation/scripts/build.ts | 32 +- .../scripts/deploy-railway.ts | 10 +- .../scripts/invalid-probe-config.error.ts | 7 + .../scripts/probe-live-lens.ts | 281 +++++++++++++ bots/vault-v2-reallocation/src/index.ts | 28 +- bots/vault-v2-reallocation/src/math.ts | 16 + bots/vault-v2-reallocation/src/runner/tick.ts | 35 +- .../src/state/lens.sol.ts | 368 ++++++++++++++++++ .../src/strategies/apy-range.ts | 4 +- .../src/strategies/equalize-utilizations.ts | 4 +- .../src/strategies/reconcile.ts | 27 +- .../vault-v2-reallocation/src/vault-checks.ts | 12 +- bots/vault-v2-reallocation/src/vault-data.ts | 263 +++++-------- .../test/runner/tick.test.ts | 7 +- .../test/state/lens.sol.test.ts | 120 ++++++ .../test/strategies/helpers.ts | 1 + .../test/strategies/reconcile.test.ts | 56 ++- .../test/vault-checks.test.ts | 13 +- .../test/vault-data.test.ts | 146 +++++++ bots/vault-v2-reallocation/tsconfig.json | 5 +- bots/vault-v2-reallocation/vitest.config.ts | 6 + knip.json | 4 +- pnpm-lock.yaml | 6 + 26 files changed, 1248 insertions(+), 263 deletions(-) create mode 100644 bots/vault-v2-reallocation/scripts/invalid-probe-config.error.ts create mode 100644 bots/vault-v2-reallocation/scripts/probe-live-lens.ts create mode 100644 bots/vault-v2-reallocation/src/state/lens.sol.ts create mode 100644 bots/vault-v2-reallocation/test/state/lens.sol.test.ts create mode 100644 bots/vault-v2-reallocation/test/vault-data.test.ts diff --git a/bots/vault-v2-reallocation/README.md b/bots/vault-v2-reallocation/README.md index 6f9cdb94..925a86d7 100644 --- a/bots/vault-v2-reallocation/README.md +++ b/bots/vault-v2-reallocation/README.md @@ -17,13 +17,20 @@ One long-running process per chain. A block watcher drives per-block queue maint 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. Concurrently re-check the EOA's allocator role (`allocator.missing_role` + skip while absent — - a pending grant never crash-loops the bot, and a fresh grant is picked up without restart) and - fetch a block-pinned RPC snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` - (which also proves the address is a factory-made VaultV2), plus per-id - `absoluteCap`/`relativeCap`/`allocation` reads for every market id, the adapter id, and each - collateral id. No Morpho API dependency. -3. Run the strategy — a pure function of that snapshot, emitting **exact-amount deltas** +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 @@ -35,7 +42,7 @@ default 10 min) throttles the actual reallocation passes. Each pass, per whiteli - **`apy-range`**: keep each market's borrow APY inside its configured range by inverting the AdaptiveCurveIRM curve; allocations top up from idle (`ALLOW_IDLE_REALLOCATION`). Fires only past `MIN_APY_DELTA_BIPS`. -4. Encode ONE `vault.multicall([deallocate…, allocate…])` (deallocations strictly first so idle is +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`). @@ -52,7 +59,10 @@ Every strategy target is additionally clamped to a **99.9% utilization ceiling** 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 against the clamped target the emitted leg actually realizes. +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 @@ -166,7 +176,20 @@ 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, 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); `DRY_RUN` against a live RPC -is the end-to-end check. +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 index 8b984b98..686926aa 100644 --- a/bots/vault-v2-reallocation/docker-compose.yml +++ b/bots/vault-v2-reallocation/docker-compose.yml @@ -2,7 +2,7 @@ # 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 (MIN_APY_DELTA_BIPS_1, MAX_FEE_GWEI_8453, …) so chains can differ; +# 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: @@ -16,7 +16,7 @@ services: 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:-} + 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:-} @@ -43,7 +43,7 @@ services: 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:-} + 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:-} diff --git a/bots/vault-v2-reallocation/package.json b/bots/vault-v2-reallocation/package.json index b46bf60b..c2b7c77f 100644 --- a/bots/vault-v2-reallocation/package.json +++ b/bots/vault-v2-reallocation/package.json @@ -8,14 +8,17 @@ "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": "tsc --noEmit" + "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": { diff --git a/bots/vault-v2-reallocation/scripts/build.ts b/bots/vault-v2-reallocation/scripts/build.ts index c488a1a3..dcb5cbdb 100644 --- a/bots/vault-v2-reallocation/scripts/build.ts +++ b/bots/vault-v2-reallocation/scripts/build.ts @@ -1,19 +1,44 @@ +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 to `dist/` so production runs a plain `node` with no runtime transform. +// 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')], + entryPoints: [join(ROOT, 'src/index.ts'), join(ROOT, 'scripts/probe-live-lens.ts')], outdir: DIST_DIR, outbase: ROOT, bundle: true, @@ -22,7 +47,8 @@ try { // 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/deploy-railway.ts b/bots/vault-v2-reallocation/scripts/deploy-railway.ts index 100ead19..11ed157c 100644 --- a/bots/vault-v2-reallocation/scripts/deploy-railway.ts +++ b/bots/vault-v2-reallocation/scripts/deploy-railway.ts @@ -8,10 +8,11 @@ * 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_`, - * `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. + * `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 @@ -291,6 +292,7 @@ const chainSecrets = CHAINS.map(chain => { // 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), 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/src/index.ts b/bots/vault-v2-reallocation/src/index.ts index 7a302f3d..c07346b1 100644 --- a/bots/vault-v2-reallocation/src/index.ts +++ b/bots/vault-v2-reallocation/src/index.ts @@ -1,5 +1,3 @@ -import type { Address } from 'viem' - import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' import { assertContractDeployed, @@ -40,41 +38,36 @@ async function main() { context: { bot: 'vault-v2-reallocation', chainId: config.chainId, ...railwayContext() } }) - // blue-sdk's vault fetch fans out many single-value reads, so the read transport batches them - // into a few JSON-RPC round trips (the per-cap-id reads are already one multicall). + // 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, - batch: true + 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 isAllocator = (vault: Address) => - readContract(client, { - address: vault, - abi: vaultV2Abi, - functionName: 'isAllocator', - args: [eoa] - }) const adapterByVault = await checkVaults( config.vaultWhitelist, { assertDeployed: vault => assertContractDeployed(client, vault, 'VAULT_WHITELIST entry'), fetchVault: vault => - fetchVaultV2Data(client, vault, { chainId: config.chainId, blockNumber: startupBlock }), + fetchVaultV2Data(client, vault, { + chainId: config.chainId, + blockNumber: startupBlock, + eoa + }), isAdapter: (vault, adapter) => readContract(client, { address: vault, abi: vaultV2Abi, functionName: 'isAdapter', args: [adapter] - }), - isAllocator + }) }, logger ) @@ -145,10 +138,9 @@ async function main() { await runTick({ vaults: config.vaultWhitelist, chainHead, - isAllocator, expectedAdapter: vault => adapterByVault[vault]?.[0], fetchVault: (vault, blockNumber) => - fetchVaultV2Data(client, vault, { chainId: config.chainId, blockNumber }), + fetchVaultV2Data(client, vault, { chainId: config.chainId, blockNumber, eoa }), strategy, encodeReallocation: (vaultData, reallocation) => encodeReallocation(vaultData.adapterAddress, reallocation), diff --git a/bots/vault-v2-reallocation/src/math.ts b/bots/vault-v2-reallocation/src/math.ts index d192986c..d950405f 100644 --- a/bots/vault-v2-reallocation/src/math.ts +++ b/bots/vault-v2-reallocation/src/math.ts @@ -40,6 +40,22 @@ export const getUtilization = (state: MarketState): bigint => ? 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 diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts index b9fb6c91..60d2c8e8 100644 --- a/bots/vault-v2-reallocation/src/runner/tick.ts +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -9,13 +9,6 @@ import type { VaultV2Data } from '../vault-data' export type TickDeps = { vaults: Address[] chainHead: bigint - /** - * Strict `isAllocator(eoa)` on the vault — deliberately narrower than the V1 bot's - * allocator|curator|owner check, because VaultV2.allocate admits no curator/owner fallback. - * Run concurrently with the fetch; a vault the EOA cannot reallocate is skipped, and resumes on - * its own once the role is granted. - */ - isAllocator: (vault: Address) => Promise /** * 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 @@ -78,12 +71,11 @@ const summarize = (reallocation: Reallocation) => [ ] const processVault = async (deps: TickDeps, vault: Address): Promise => { - const [vaultData, isAllocator] = await Promise.all([ - deps.fetchVault(vault, deps.chainHead), - deps.isAllocator(vault) - ]) + const vaultData = await deps.fetchVault(vault, deps.chainHead) - if (!isAllocator) { + // 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 } } @@ -134,17 +126,19 @@ const processVault = async (deps: TickDeps, vault: Address): Promise => { const started = Date.now() const inflight = deps.inflightLabels() - const settled = await Promise.allSettled( + // 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 }) @@ -159,10 +153,9 @@ export const runTick = async (deps: TickDeps): Promise => { }) ) - const counters = settled.reduce( + const counters = results.reduce( (acc, result) => { - if (result.status === 'rejected') return { ...acc, errors: acc.errors + 1 } - for (const key of COUNTER_KEYS) acc[key] += result.value[key] + for (const key of COUNTER_KEYS) acc[key] += result[key] return acc }, { ...NO_COUNTS } 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 index 587653c4..b923a565 100644 --- a/bots/vault-v2-reallocation/src/strategies/apy-range.ts +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -67,8 +67,8 @@ export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => return { targetUtilization, intent: utilization > bound ? 'allocate' : 'deallocate', - clearsMinDelta: - apyDeltaBips(utilization, targetUtilization, rateAtTarget) > + 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 index b889dfa3..e135de8f 100644 --- a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -56,8 +56,8 @@ export const createEqualizeUtilizationsStrategy = (config: EqualizeUtilizationsC return { targetUtilization, intent: utilization > rawTarget ? 'allocate' : 'deallocate', - clearsMinDelta: - Math.abs(wadToBips(utilization - targetUtilization)) > minUtilizationDeltaBips + clearsMinDelta: utilizationAfter => + Math.abs(wadToBips(utilization - utilizationAfter)) > minUtilizationDeltaBips } } } diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts index 99709836..407c7a22 100644 --- a/bots/vault-v2-reallocation/src/strategies/reconcile.ts +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -8,6 +8,7 @@ import { creditPools, getDepositableAmount, getUtilization, + getUtilizationAfter, getWithdrawableAmount, MAX_TARGET_UTILIZATION, takeFromPools @@ -26,8 +27,13 @@ export type MarketTarget = { * then emit the opposite leg. */ intent: MoveIntent - /** Whether this market's own move — measured against the CLAMPED target — clears the min-delta. */ - clearsMinDelta: boolean + /** + * 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. */ @@ -50,7 +56,7 @@ type SizedMove = { marketData: VaultV2MarketData side: MoveIntent amount: bigint - clearsMinDelta: boolean + clearsMinDelta: MarketTarget['clearsMinDelta'] } const { min } = MathLib @@ -67,9 +73,10 @@ const toLeg = ({ marketData }: SizedMove, assets: bigint): ReallocationAction => * 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: a market whose move clears the - * threshold but whose take is entirely consumed by the budget or the cap pools cannot arm the - * plan, so a fired plan always contains at least one surviving leg worth its transaction. + * 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 @@ -170,7 +177,9 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { remainingAmountToDeallocate -= toDeallocate creditPools(legPools, move.marketData.params.collateralToken, toDeallocate) if (toDeallocate > 0n) { - didClearMinDelta ||= move.clearsMinDelta + didClearMinDelta ||= move.clearsMinDelta( + getUtilizationAfter(move.marketData.state, move.side, toDeallocate) + ) deallocations.push(toLeg(move, toDeallocate)) } } @@ -184,7 +193,9 @@ export const createReconciler = (options: ReconcilerOptions): Strategy => { ) remainingAmountToAllocate -= toAllocate if (toAllocate > 0n) { - didClearMinDelta ||= move.clearsMinDelta + didClearMinDelta ||= move.clearsMinDelta( + getUtilizationAfter(move.marketData.state, move.side, toAllocate) + ) allocations.push(toLeg(move, toAllocate)) } } diff --git a/bots/vault-v2-reallocation/src/vault-checks.ts b/bots/vault-v2-reallocation/src/vault-checks.ts index 1b76550c..b773d93e 100644 --- a/bots/vault-v2-reallocation/src/vault-checks.ts +++ b/bots/vault-v2-reallocation/src/vault-checks.ts @@ -1,8 +1,6 @@ import type { Logger } from '@repo/bot-kit' import type { Address } from 'viem' -import { tryCatch } from '@repo/utils' - import type { VaultV2Data } from './vault-data' import { InvalidVaultError } from './invalid-vault.error' @@ -11,14 +9,13 @@ export type VaultCheckReads = { /** Fatal liveness gate; throws when the address holds no code on this chain. */ assertDeployed: (vault: Address) => Promise /** - * Block-pinned V2 fetch; throws {@link InvalidVaultError} (or the SDK's factory rejection) when - * the address is not a factory-made VaultV2 with exactly one Morpho Blue market adapter. + * Block-pinned lens fetch; throws {@link 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). */ fetchVault: (vault: Address) => Promise /** `vault.isAdapter(adapter)` cross-check that the vault recognizes its fetched adapter. */ isAdapter: (vault: Address, adapter: Address) => Promise - /** Strict `isAllocator(eoa)` — VaultV2.allocate admits no curator/owner fallback. */ - isAllocator: (vault: Address) => Promise } /** @@ -45,8 +42,7 @@ export const checkVaults = async ( ) } adapterByVault[vault] = [vaultData.adapterAddress] - const role = await tryCatch(reads.isAllocator(vault)) - if (role.error || !role.data) { + if (!vaultData.isAllocator) { logger.warn('allocator.missing_role', { vault, detail: 'grant the allocator role to the EOA' diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts index 5b3cde63..98eacc60 100644 --- a/bots/vault-v2-reallocation/src/vault-data.ts +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -1,18 +1,14 @@ -import type { InputMarketParams, MarketParams } from '@morpho-org/blue-sdk' -import type { Address, Client, Hex } from 'viem' +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { BatchLensTransportType } from '@repo/utils' +import type { Address, Client, Hex, Transport } from 'viem' -import { - AccrualVaultV2MorphoMarketV1Adapter, - AccrualVaultV2MorphoMarketV1AdapterV2, - getChainAddresses, - SharesMath, - VaultV2MorphoMarketV1Adapter -} from '@morpho-org/blue-sdk' -import { fetchAccrualVaultV2, vaultV2Abi } from '@morpho-org/blue-sdk-viem' -import { getAddress, isAddressEqual } from 'viem' -import { getBlock, multicall } from 'viem/actions' +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 @@ -54,7 +50,13 @@ export type VaultV2MarketData = { export type VaultV2Data = { vaultAddress: Address adapterAddress: Address - /** The vault's total assets, interest accrued to the pinned block's timestamp. */ + /** + * 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 @@ -80,171 +82,68 @@ export type VaultV2Data = { const isAdaptiveCurveMarket = (irm: Address, rateAtTarget: bigint, chainId: number): boolean => rateAtTarget > 0n && isAddressEqual(irm, getChainAddresses(chainId).adaptiveCurveIrm) -type MarketAdapter = AccrualVaultV2MorphoMarketV1Adapter | AccrualVaultV2MorphoMarketV1AdapterV2 - -type AdapterMarket = { - params: MarketParams - state: MarketState - vaultAssets: bigint - rateAtTarget: bigint - isIdle: boolean -} - -// Both Morpho Blue market adapter generations take the same abi-encoded market params in -// allocate/deallocate and derive identical cap ids; they differ only in how the SDK models their -// positions (AccrualPosition list vs supplyShares per market id). Normalize to one shape. -const normalizeAdapterMarkets = (adapter: MarketAdapter, timestamp: bigint): AdapterMarket[] => { - if (adapter instanceof AccrualVaultV2MorphoMarketV1Adapter) { - return adapter.marketParamsList.map(params => { - const position = adapter.positions.find(candidate => candidate.marketId === params.id) - if (position === undefined) { - throw new InvalidVaultError(`adapter position missing for market ${params.id}`) - } - const accrued = position.accrueInterest(timestamp) - return { - params, - state: accrued.market, - vaultAssets: accrued.supplyAssets, - rateAtTarget: accrued.market.rateAtTarget ?? 0n, - isIdle: accrued.market.isIdle - } - }) - } - return adapter.markets.map(market => { - const accrued = market.accrueInterest(timestamp) - return { - params: accrued.params, - state: accrued, - vaultAssets: SharesMath.toAssets( - adapter.supplyShares[accrued.id] ?? 0n, - accrued.totalSupplyAssets, - accrued.totalSupplyShares, - 'Down' - ), - rateAtTarget: accrued.rateAtTarget ?? 0n, - isIdle: accrued.isIdle - } - }) -} - -const CAP_FUNCTIONS = ['absoluteCap', 'relativeCap', 'allocation'] as const - -// One multicall for all ids' cap state — 3 × (markets + collaterals + 1) reads would otherwise be -// individual eth_calls per tick. -const readCaps = async ( - client: Client, - vault: Address, - ids: readonly Hex[], - blockNumber: bigint -): Promise => { - const results = await multicall(client, { - allowFailure: false, - blockNumber, - contracts: ids.flatMap(id => - CAP_FUNCTIONS.map(functionName => ({ - address: vault, - abi: vaultV2Abi, - functionName, - args: [id] as const - })) - ) - }) - return ids.map((_, i) => ({ - absolute: results[i * CAP_FUNCTIONS.length] as bigint, - relative: results[i * CAP_FUNCTIONS.length + 1] as bigint, - allocation: results[i * CAP_FUNCTIONS.length + 2] as bigint - })) -} +const toCapState = (caps: { + absoluteCap: bigint + relativeCap: bigint + allocation: bigint +}): CapState => ({ + absolute: caps.absoluteCap, + relative: caps.relativeCap, + allocation: caps.allocation +}) /** - * Reads one VaultV2's full reallocation input over RPC, pinned to `blockNumber` for a coherent - * snapshot: the accrued vault tree via blue-sdk's `fetchAccrualVaultV2` (which also proves the - * address is a factory-made VaultV2), plus the per-id cap/allocation reads the SDK fetcher does not - * cover for a regular adapter (market ids, the adapter id, and each distinct collateral id). - * Throws {@link InvalidVaultError} unless the vault has exactly one adapter and it is a Morpho Blue - * market adapter (either adapter-contract generation). Throws on any failed read — the tick - * catches per vault. + * 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 fetchVaultV2Data = async ( - client: Client, - vault: Address, - { chainId, blockNumber }: { chainId: number; blockNumber: bigint } -): Promise => { - // Accrue to the pinned block's timestamp, not wall clock, so the snapshot is coherent and - // reproducible against the pinned reads. - const [{ timestamp }, vaultV2] = await Promise.all([ - getBlock(client, { blockNumber }), - fetchAccrualVaultV2(vault, client, { chainId, blockNumber }) - ]) - - const marketAdapters = vaultV2.accrualAdapters.filter( - (adapter): adapter is MarketAdapter => - adapter instanceof AccrualVaultV2MorphoMarketV1Adapter || - adapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2 - ) - if (vaultV2.adapters.length !== 1 || marketAdapters.length !== 1) { +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 ` + - `${vaultV2.adapters.length} adapter(s) of which ${marketAdapters.length} qualify` + `${row.adapters.length} adapter(s) of which ${qualifying.length} qualify` ) } - const adapter = marketAdapters[0]! - const adapterAddress = getAddress(adapter.address) - - const adapterMarkets = normalizeAdapterMarkets(adapter, timestamp) - const collateralTokens = [ - ...new Set(adapterMarkets.map(({ params }) => getAddress(params.collateralToken))) - ] - - // Both adapter generations derive identical "this"/"collateralToken"/"this/marketParams" ids. - const marketCapIds = adapterMarkets.map(({ params }) => - VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, params) - ) - const caps = await readCaps( - client, - vault, - [ - VaultV2MorphoMarketV1Adapter.adapterId(adapterAddress), - ...collateralTokens.map(token => VaultV2MorphoMarketV1Adapter.collateralId(token)), - ...marketCapIds - ], - blockNumber - ) - const adapterCap = caps[0]! - const collateralCaps = Object.fromEntries( - collateralTokens.map((token, i) => [token, caps[1 + i]!]) - ) - const marketCaps = caps.slice(1 + collateralTokens.length) + const adapterAddress = getAddress(qualifying[0]!.adapter) - const marketsData = adapterMarkets.map( - ({ params, state, vaultAssets, rateAtTarget, isIdle }, i): VaultV2MarketData => ({ - id: params.id, - capId: marketCapIds[i]!, - params: { - loanToken: params.loanToken, - collateralToken: params.collateralToken, - oracle: params.oracle, - irm: params.irm, - lltv: params.lltv - }, + const marketsData = row.markets.map( + (market): VaultV2MarketData => ({ + id: market.id, + capId: market.capId, + params: market.params, state: { - totalSupplyAssets: state.totalSupplyAssets, - totalBorrowAssets: state.totalBorrowAssets + totalSupplyAssets: market.totalSupplyAssets, + totalBorrowAssets: market.totalBorrowAssets }, - cap: marketCaps[i]!, - vaultAssets, - rateAtTarget, - isAdaptiveCurve: isAdaptiveCurveMarket(params.irm, rateAtTarget, chainId), - isIdle + 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, - totalAssets: vaultV2.accrueInterest(timestamp).vault._totalAssets, - idleAssets: vaultV2.assetBalance, - adapterCap, + isAllocator: row.isAllocator, + totalAssets: row.totalAssets, + idleAssets: row.idleAssets, + adapterCap: toCapState(row.adapterCap), collateralCaps, marketsData, nonAdaptiveCurveMarketIds: marketsData @@ -252,3 +151,43 @@ export const fetchVaultV2Data = async ( .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/runner/tick.test.ts b/bots/vault-v2-reallocation/test/runner/tick.test.ts index 310cf1bd..b0de7553 100644 --- a/bots/vault-v2-reallocation/test/runner/tick.test.ts +++ b/bots/vault-v2-reallocation/test/runner/tick.test.ts @@ -43,7 +43,6 @@ const makeDeps = (overrides: Partial = {}) => { const deps: TickDeps = { vaults: [VAULT_A], chainHead: 100n, - isAllocator: vi.fn(async () => true), expectedAdapter: vi.fn(() => undefined), fetchVault: vi.fn(async () => someVaultData()), strategy: vi.fn(() => undefined), @@ -122,7 +121,6 @@ describe('runTick', () => { it('skips a vault whose label is in flight', async () => { const { deps, events } = makeDeps({ inflightLabels: () => new Set([VAULT_A]) }) await runTick(deps) - expect(deps.isAllocator).not.toHaveBeenCalled() expect(deps.fetchVault).not.toHaveBeenCalled() expect(tickEnd(events)).toMatchObject({ skipped_inflight: 1 }) }) @@ -140,8 +138,9 @@ describe('runTick', () => { }) it('skips strategy/simulate while the allocator role is missing', async () => { - // The fetch runs concurrently with the role read, so it is issued regardless. - const { deps, events } = makeDeps({ isAllocator: vi.fn(async () => false) }) + const { deps, events } = makeDeps({ + fetchVault: vi.fn(async () => ({ ...someVaultData(), isAllocator: false })) + }) await runTick(deps) expect(deps.strategy).not.toHaveBeenCalled() expect(events).toContainEqual({ 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/helpers.ts b/bots/vault-v2-reallocation/test/strategies/helpers.ts index 34be3349..3b3523db 100644 --- a/bots/vault-v2-reallocation/test/strategies/helpers.ts +++ b/bots/vault-v2-reallocation/test/strategies/helpers.ts @@ -74,6 +74,7 @@ export const makeVaultData = ( ): VaultV2Data => ({ vaultAddress: VAULT, adapterAddress: ADAPTER, + isAllocator: true, totalAssets: markets.reduce((acc, m) => acc + m.vaultAssets, 0n) + (overrides.idleAssets ?? 0n), idleAssets: 0n, adapterCap: UNLIMITED_CAP, diff --git a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts index 1d7575ad..e43d7d10 100644 --- a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import type { Classify, MarketTarget } from '../../src/strategies/reconcile' -import { getUtilization } from '../../src/math' +import { getUtilization, wadToBips } from '../../src/math' import { createReconciler } from '../../src/strategies/reconcile' import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' @@ -27,7 +27,7 @@ const toTarget = (marketData): MarketTarget => ({ targetUtilization: rawTarget, intent: getUtilization(marketData.state) > rawTarget ? 'allocate' : 'deallocate', - clearsMinDelta + clearsMinDelta: () => clearsMinDelta }) // Every market converges on 50% utilization and always clears the gate. @@ -295,6 +295,58 @@ describe('createReconciler', () => { 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('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. diff --git a/bots/vault-v2-reallocation/test/vault-checks.test.ts b/bots/vault-v2-reallocation/test/vault-checks.test.ts index eeb32bba..99defa20 100644 --- a/bots/vault-v2-reallocation/test/vault-checks.test.ts +++ b/bots/vault-v2-reallocation/test/vault-checks.test.ts @@ -31,7 +31,6 @@ const makeReads = (overrides: Partial = {}): VaultCheckReads => assertDeployed: vi.fn(async () => undefined), fetchVault: vi.fn(async () => someVaultData()), isAdapter: vi.fn(async () => true), - isAllocator: vi.fn(async () => true), ...overrides }) @@ -61,17 +60,13 @@ describe('checkVaults', () => { ).rejects.toBeInstanceOf(InvalidVaultError) }) - it('warns but does not throw when the allocator role is missing or unreadable', async () => { + it('warns but does not throw when the allocator role is missing', async () => { const { logger, events } = spyLogger() - await checkVaults([VAULT], makeReads({ isAllocator: vi.fn(async () => false) }), logger) - expect(events.some(e => e.event === 'allocator.missing_role' && e.level === 'warn')).toBe(true) - - const { logger: logger2, events: events2 } = spyLogger() await checkVaults( [VAULT], - makeReads({ isAllocator: vi.fn(async () => Promise.reject(new Error('rpc'))) }), - logger2 + makeReads({ fetchVault: vi.fn(async () => ({ ...someVaultData(), isAllocator: false })) }), + logger ) - expect(events2.some(e => e.event === 'allocator.missing_role')).toBe(true) + 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 index 9863d08e..dccc6c59 100644 --- a/bots/vault-v2-reallocation/tsconfig.json +++ b/bots/vault-v2-reallocation/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "@repo/typescript-config/base", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "plugins": [{ "name": "soltag/plugin" }] }, - "include": ["src", "test", "scripts"], + "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 index 1be7d9fc..faf2d50e 100644 --- a/bots/vault-v2-reallocation/vitest.config.ts +++ b/bots/vault-v2-reallocation/vitest.config.ts @@ -1,6 +1,12 @@ +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 11ed157a..d6927413 100644 --- a/knip.json +++ b/knip.json @@ -26,7 +26,9 @@ "bots/vault-v1-reallocation": { "entry": ["src/index.ts", "scripts/probe-live-lens.ts"] }, - "bots/vault-v2-reallocation": {}, + "bots/vault-v2-reallocation": { + "entry": ["src/index.ts", "scripts/probe-live-lens.ts"] + }, "bots/midnight-crossed-books": { "ignore": ["src/infrastructure/*/generated/**"] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa550bd4..a5229a70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,6 +469,12 @@ importers: '@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)