diff --git a/.agents/pr-review-engine/agents/documentation.md b/.agents/pr-review-engine/agents/documentation.md index 475eaed3c..980962ea0 100644 --- a/.agents/pr-review-engine/agents/documentation.md +++ b/.agents/pr-review-engine/agents/documentation.md @@ -50,6 +50,7 @@ For each Markdown file affected, flag: - **Stale prose.** A statement that no longer matches the code after the diff — e.g. README documents a function that was removed/renamed; AGENTS.md lists a rule the code change just violated; an example that no longer compiles. - **Out-of-sync inventories.** A file enumerating personas, packages, slash commands, scripts, supported chains, etc. that no longer matches reality after the diff. E.g. a README that lists "supported chains: mainnet, base" while the diff just added arbitrum. - **Cross-doc consistency.** When the diff changes a rule in `AGENTS.md`, every persona that enforces it (per the backlink `> Applied by personas: …`) should reflect the new rule. When the diff renames a section heading in `AGENTS.md`, every doc that references that section by title needs an update. +- **Implemented TIB rewrites.** Treat a TIB already present on the target branch as a historical record. Flag edits that update its implementation-time names or examples; changed decisions require a superseding TIB, while operational clarifications require a dated addendum. A TIB introduced on the current branch may stay in sync with its implementation before landing. - **Code blocks that drift from the code.** A bash snippet in a `.md` that uses a flag the script no longer supports; a TypeScript snippet whose imports no longer resolve. ## 3. Pointer / link integrity diff --git a/.changeset/brave-vaults-reallocate.md b/.changeset/brave-vaults-reallocate.md new file mode 100644 index 000000000..4162365d4 --- /dev/null +++ b/.changeset/brave-vaults-reallocate.md @@ -0,0 +1,20 @@ +--- +"@morpho-org/morpho-ts": minor +"@morpho-org/blue-sdk": minor +"@morpho-org/blue-sdk-viem": minor +"@morpho-org/liquidity-sdk-viem": patch +"@morpho-org/morpho-sdk": minor +"@morpho-org/wdk-protocol-lending-morpho-evm": minor +--- + +Add canonical `vaultV1PublicAllocatorAbi` and `vaultV2BluePublicAllocatorAbi` exports plus per-chain `vaultV1PublicAllocator` and `vaultV2BluePublicAllocator` registry entries to `morpho-ts`, preserving `publicAllocatorAbi` and `publicAllocator` as deprecated V1 aliases. Move the shared `marketParamsAbi` source of truth there while preserving its `blue-sdk` re-export, and raise the `blue-sdk` peer range to the introducing `morpho-ts` minor. Add Vault V2 allocation-cap helpers and the updated `canPullFromIdle`/`canPullFromMarket`/WAD-scaled penalty config types to `blue-sdk`, accept iterable active-adapter, vault-allowlist, and reallocation-plan inputs while materializing them before repeated use, add chain-registry-backed deployless and fallback reads to `blue-sdk-viem`, and expose Vault V2 shared-liquidity discovery, planning, metrics, maximum-penalty filtering, and flat market/idle reallocations through `morpho-sdk` Blue flows. + +V2 bundles now reject chains without a registered BluePublicAllocator before exposing requirements, pull the proportional loan-token penalty through GeneralAdapter1, grant the allocator an exact non-skippable allowance from Bundler3, pass the configured `uint64 penalty` in calldata, and keep the nonpayable allocator calls out of `tx.value`. `VaultV2BluePublicAllocatorConfig` is hydrated as a class with exact per-call penalty calculation, `VaultV2BlueMarketPublicAllocatorConfig` computes max-in capacity from its absolute cap, and plan totals stay local to their consumers. The planner mirrors contract execution order for penalties, source deallocation, first vault accrual (including zero-elapsed loss recognition), and target allocation; freezes the resulting relative-cap denominator across later calls for that vault; keeps every adapter coherent with one canonical simulated state per Morpho market; preserves supplied address casing while matching vaults and adapters case-insensitively; rejects incomplete allocator snapshots instead of silently reporting no liquidity; rejects non-positive operation amounts and same-market moves across adapters; and uses the latest timestamp in its complete input snapshot by default. + +Use coherent protocol-specific names across the V1 and V2 reallocation APIs, including `VaultV1ReallocationData`, `VaultV2BlueReallocationData`, `computeVaultV1Reallocations`, `VaultV2BluePublicAllocatorOptions`, `VaultV2BluePublicAllocatorConfig`, its fetcher family, and Vault V2-prefixed Bundler actions. Add `MorphoBlue.getVaultV1ReallocationData`, `getVaultV1Reallocations`, `getVaultV2BlueReallocationData`, and `getVaultV2BlueReallocations`; preserve the published unversioned `getReallocationData` and `getReallocations` as deprecated V1 aliases. Both versioned planners reject reallocation snapshots from another chain. Keep V1's `defaultMaxWithdrawalUtilization` configurable, and add V2's scalar `maxWithdrawalUtilization` for its friendly phase while retaining the 100% adversarial fallback. + +Compatibility note: this minor intentionally accepts four breaking changes. `VaultV2MorphoMarketV1Adapter.ids()` and `VaultV2MorphoMarketV1AdapterV2.ids()` now return the labeled readonly tuple `readonly [adapterCapId: Hash, collateralCapId: Hash, adapterMarketCapId: Hash]` instead of mutable `Hash[]`, while `VaultV2MorphoVaultV1Adapter.ids()` now returns `readonly [adapterCapId: Hash]`; `MorphoBlue.withdraw`, `borrow`, and `refinance` may now return `Transaction` from `getRequirements()` for Vault V2 penalty funding; `BlueWithdrawAction`, `BlueBorrowAction`, `BlueSupplyCollateralBorrowAction`, and `BlueRefinanceAction` now require `reallocationPenaltyAssets`; and Vault V2 reallocation discovery now accepts only zero-penalty vaults by default. Runtime ordering for `ids()` is unchanged. Consumers should spread `ids()` when a mutable array is required, handle approval transactions in exhaustive requirement consumers, set `reallocationPenaltyAssets: 0n` in handwritten V1 or no-penalty action descriptors, and explicitly set `maxPenalty` when opting into a nonzero Vault V2 allocator penalty. Explicit and hand-built penalties remain supported up to WAD (100%), preserving the existing maximum. + +Name allocation-cap helpers `adapterCapId`, `collateralCapId`, and `adapterMarketCapId`. Preserve the published `adapterId`, `collateralId`, and `marketParamsId` helpers as deprecated aliases. + +Add an explicit `MorphoBorrowWithVaultV2ReallocationsOptions` WDK opt-in for Vault V2 reallocations and their possible approval requirement while preserving the legacy Vault V1-only `MorphoBorrowOptions` input and authorization-only requirement result type. Reallocation plans must use exactly one vault version per transaction. diff --git a/AGENTS.md b/AGENTS.md index 9eb7a3002..eb8c52158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,8 @@ A scannable list of patterns reviewers reject. Most are review-only today (per t - `@returns` describing the return shape. - `@throws` for each typed error class an integrator may pattern-match on. - One `@example` block with realistic working code: imports, client setup, the call, expected return. -- **AI-legibility is first-class.** Identical signatures across V1/V2 where protocols overlap. Discriminated unions with obvious `type` tags. Deterministic outputs verifiable byte-for-byte. Error messages read like instructions an agent can act on without guessing. Protocol-specific terms (`LLTV buffer`, `wNative`, `GeneralAdapter1`, `bundler3`, `PublicAllocator`, `MetaMorpho`, `Permit2`, `WAD`) live in the [`packages/morpho-sdk/AGENTS.md`](./packages/morpho-sdk/AGENTS.md) glossary. +- **AI-legibility is first-class.** Identical signatures across V1/V2 where protocols overlap. Discriminated unions with obvious `type` tags. Deterministic outputs verifiable byte-for-byte. Error messages read like instructions an agent can act on without guessing. Protocol-specific terms (`LLTV buffer`, `wNative`, `GeneralAdapter1`, `bundler3`, `PublicAllocator V1`, `BluePublicAllocator`, `MetaMorpho`, `Permit2`, `WAD`) live in the [`packages/morpho-sdk/AGENTS.md`](./packages/morpho-sdk/AGENTS.md) glossary. +- **Implemented TIBs are historical records.** Do not rewrite a TIB already present on the target branch to follow later code, symbol, or path changes. Keep the TIB's implementation-time names and examples intact. A changed decision gets a new superseding TIB; an operational clarification gets a dated addendum. Only a TIB introduced for the current implementation may be kept in sync with that implementation before it lands. - **TypeDoc-generated reference** published per release. - **Feedback loop:** if the same question is asked twice, the answer goes into the relevant `AGENTS.md` or JSDoc on the export it concerns. diff --git a/packages/blue-sdk-viem/AGENTS.md b/packages/blue-sdk-viem/AGENTS.md index 974c6a04d..cb5db342b 100644 --- a/packages/blue-sdk-viem/AGENTS.md +++ b/packages/blue-sdk-viem/AGENTS.md @@ -12,6 +12,7 @@ - Normalize unsafe user addresses with `safeGetAddress`, not lowercasing alone. - Typed-data helpers return `TypedDataDefinition`, e.g. `getPermitTypedData(...)`. - Re-export ABI literals from `@morpho-org/morpho-ts` when they exist there; keep local ABI declarations only for Blue-specific viem surfaces absent from `morpho-ts`. +- Vault V2 BluePublicAllocator fetchers resolve the chain's single allocator from `vaultV2BluePublicAllocator` in the address registry, using `parameters.chainId` or the client chain id. The hydrated-vault batch fetch derives supported adapter/market/allocation ids, returns active adapters as a set separate from market configs, defaults to one deployless read, and falls back to direct reads. ## Continuous Improvement diff --git a/packages/blue-sdk-viem/contracts/vault-v2/GetVaultV2BluePublicAllocatorConfig.sol b/packages/blue-sdk-viem/contracts/vault-v2/GetVaultV2BluePublicAllocatorConfig.sol new file mode 100644 index 000000000..c574e3836 --- /dev/null +++ b/packages/blue-sdk-viem/contracts/vault-v2/GetVaultV2BluePublicAllocatorConfig.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {IBluePublicAllocator} from "./interfaces/IBluePublicAllocator.sol"; +import {IVaultV2} from "./interfaces/IVaultV2.sol"; + +struct VaultV2BlueMarketPublicAllocatorRequest { + address adapter; + bytes32 adapterMarketCapId; +} + +struct VaultV2BlueMarketPublicAllocatorResponse { + address adapter; + bytes32 adapterMarketCapId; + uint256 absoluteCap; + bool canPullFromMarket; +} + +struct VaultV2AllocationResponse { + bytes32 id; + uint256 absoluteCap; + uint256 relativeCap; + uint256 allocation; +} + +struct VaultV2BluePublicAllocatorResponse { + bool isAllocator; + bool canPullFromIdle; + uint64 penalty; + bool[] isActiveAdapters; + VaultV2BlueMarketPublicAllocatorResponse[] marketConfigs; + VaultV2AllocationResponse[] allocations; +} + +contract GetVaultV2BluePublicAllocatorConfig { + function query( + IBluePublicAllocator allocator, + IVaultV2 vault, + address[] calldata adapters, + VaultV2BlueMarketPublicAllocatorRequest[] calldata marketRequests, + bytes32[] calldata allocationIds + ) external view returns (VaultV2BluePublicAllocatorResponse memory res) { + res.isAllocator = vault.isAllocator(address(allocator)); + (res.canPullFromIdle, res.penalty) = allocator.vaultData(address(vault)); + + uint256 adaptersLength = adapters.length; + res.isActiveAdapters = new bool[](adaptersLength); + for (uint256 i; i < adaptersLength; ++i) { + res.isActiveAdapters[i] = allocator.isActiveAdapter(address(vault), adapters[i]); + } + + uint256 marketRequestsLength = marketRequests.length; + res.marketConfigs = new VaultV2BlueMarketPublicAllocatorResponse[](marketRequestsLength); + for (uint256 i; i < marketRequestsLength; ++i) { + VaultV2BlueMarketPublicAllocatorRequest calldata request = marketRequests[i]; + res.marketConfigs[i] = VaultV2BlueMarketPublicAllocatorResponse({ + adapter: request.adapter, + adapterMarketCapId: request.adapterMarketCapId, + absoluteCap: allocator.absoluteCap(address(vault), request.adapterMarketCapId), + canPullFromMarket: allocator.canPullFromMarket(address(vault), request.adapterMarketCapId) + }); + } + + uint256 allocationIdsLength = allocationIds.length; + res.allocations = new VaultV2AllocationResponse[](allocationIdsLength); + for (uint256 i; i < allocationIdsLength; ++i) { + bytes32 id = allocationIds[i]; + res.allocations[i] = VaultV2AllocationResponse({ + id: id, + absoluteCap: vault.absoluteCap(id), + relativeCap: vault.relativeCap(id), + allocation: vault.allocation(id) + }); + } + } +} diff --git a/packages/blue-sdk-viem/contracts/vault-v2/interfaces/IBluePublicAllocator.sol b/packages/blue-sdk-viem/contracts/vault-v2/interfaces/IBluePublicAllocator.sol new file mode 100644 index 000000000..b6b788239 --- /dev/null +++ b/packages/blue-sdk-viem/contracts/vault-v2/interfaces/IBluePublicAllocator.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity ^0.8.0; + +interface IBluePublicAllocator { + function absoluteCap(address vault, bytes32 id) external view returns (uint256); + function canPullFromMarket(address vault, bytes32 id) external view returns (bool); + function isActiveAdapter(address vault, address adapter) external view returns (bool); + function vaultData(address vault) external view returns (bool canPullFromIdle, uint64 penalty); +} diff --git a/packages/blue-sdk-viem/package.json b/packages/blue-sdk-viem/package.json index 29cadd0b4..4cd69355e 100644 --- a/packages/blue-sdk-viem/package.json +++ b/packages/blue-sdk-viem/package.json @@ -30,8 +30,8 @@ "build:esm": "tsc --build tsconfig.build.esm.json && echo '{\"type\":\"module\"}' > lib/esm/package.json" }, "peerDependencies": { - "@morpho-org/blue-sdk": "^6.4.0", - "@morpho-org/morpho-ts": "^2.7.0", + "@morpho-org/blue-sdk": "^6.6.0", + "@morpho-org/morpho-ts": "^2.10.0", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/blue-sdk-viem/src/abis.ts b/packages/blue-sdk-viem/src/abis.ts index 4be94d4ff..db76695f1 100644 --- a/packages/blue-sdk-viem/src/abis.ts +++ b/packages/blue-sdk-viem/src/abis.ts @@ -14,7 +14,9 @@ export { publicAllocatorAbi, vaultV1AdapterAbi, vaultV1AdapterFactoryAbi, + vaultV1PublicAllocatorAbi, vaultV2Abi, + vaultV2BluePublicAllocatorAbi, vaultV2FactoryAbi, whitelistControllerAggregatorV2Abi, wrappedBackedTokenAbi, diff --git a/packages/blue-sdk-viem/src/fetch/Vault.ts b/packages/blue-sdk-viem/src/fetch/Vault.ts index 638119400..f5524bfc6 100644 --- a/packages/blue-sdk-viem/src/fetch/Vault.ts +++ b/packages/blue-sdk-viem/src/fetch/Vault.ts @@ -15,7 +15,7 @@ import { getChainId, readContract } from "viem/actions"; import { metaMorphoAbi, metaMorphoFactoryAbi, - publicAllocatorAbi, + vaultV1PublicAllocatorAbi, } from "../abis.js"; import { abi, code } from "../queries/GetVault.js"; import type { DeploylessFetchParameters } from "../types.js"; @@ -60,7 +60,7 @@ export async function fetchVault( ) { parameters.chainId ??= await getChainId(client); - const { publicAllocator, metaMorphoFactory } = getChainAddresses( + const { vaultV1PublicAllocator, metaMorphoFactory } = getChainAddresses( parameters.chainId, ); @@ -95,7 +95,11 @@ export async function fetchVault( abi, code, functionName: "query", - args: [address, publicAllocator ?? zeroAddress, metaMorphoFactory], + args: [ + address, + vaultV1PublicAllocator ?? zeroAddress, + metaMorphoFactory, + ], }); return new Vault({ @@ -115,7 +119,7 @@ export async function fetchVault( pendingGuardian, pendingTimelock, publicAllocatorConfig: - publicAllocator != null ? publicAllocatorConfig : undefined, + vaultV1PublicAllocator != null ? publicAllocatorConfig : undefined, supplyQueue: supplyQueue as MarketId[], withdrawQueue: withdrawQueue as MarketId[], totalSupply, @@ -247,13 +251,13 @@ export async function fetchVault( abi: metaMorphoAbi, functionName: "withdrawQueueLength", }), - publicAllocator != null && + vaultV1PublicAllocator != null && readContract(client, { ...parameters, address, abi: metaMorphoAbi, functionName: "isAllocator", - args: [publicAllocator], + args: [vaultV1PublicAllocator], }), readContract(client, { ...parameters, @@ -284,22 +288,22 @@ export async function fetchVault( publicAllocatorConfigPromise = Promise.all([ readContract(client, { ...parameters, - address: publicAllocator!, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator!, + abi: vaultV1PublicAllocatorAbi, functionName: "admin", args: [address], }), readContract(client, { ...parameters, - address: publicAllocator!, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator!, + abi: vaultV1PublicAllocatorAbi, functionName: "fee", args: [address], }), readContract(client, { ...parameters, - address: publicAllocator!, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator!, + abi: vaultV1PublicAllocatorAbi, functionName: "accruedFee", args: [address], }), diff --git a/packages/blue-sdk-viem/src/fetch/VaultMarketPublicAllocatorConfig.ts b/packages/blue-sdk-viem/src/fetch/VaultMarketPublicAllocatorConfig.ts index 9d4b929e8..36da44757 100644 --- a/packages/blue-sdk-viem/src/fetch/VaultMarketPublicAllocatorConfig.ts +++ b/packages/blue-sdk-viem/src/fetch/VaultMarketPublicAllocatorConfig.ts @@ -5,7 +5,7 @@ import { } from "@morpho-org/blue-sdk"; import type { Address, Client } from "viem"; import { getChainId, readContract } from "viem/actions"; -import { publicAllocatorAbi } from "../abis.js"; +import { vaultV1PublicAllocatorAbi } from "../abis.js"; import type { FetchParameters } from "../types.js"; /** @@ -49,14 +49,14 @@ export async function fetchVaultMarketPublicAllocatorConfig( ) { parameters.chainId ??= await getChainId(client); - const { publicAllocator } = getChainAddresses(parameters.chainId); + const { vaultV1PublicAllocator } = getChainAddresses(parameters.chainId); /* v8 ignore next: V8 does not credit this guard's empty false branch; both paths are tested. */ - if (publicAllocator == null) return; + if (vaultV1PublicAllocator == null) return; const [maxIn, maxOut] = await readContract(client, { ...parameters, - address: publicAllocator, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "flowCaps", args: [vault, marketId], }); diff --git a/packages/blue-sdk-viem/src/fetch/fetch.test.ts b/packages/blue-sdk-viem/src/fetch/fetch.test.ts index bd37a61bd..cc0bb2a21 100644 --- a/packages/blue-sdk-viem/src/fetch/fetch.test.ts +++ b/packages/blue-sdk-viem/src/fetch/fetch.test.ts @@ -52,7 +52,7 @@ import { permissionedErc20WrapperAbi, permit2Abi, preLiquidationAbi, - publicAllocatorAbi, + vaultV1PublicAllocatorAbi, whitelistControllerAggregatorV2Abi, wrappedBackedTokenAbi, wstEthAbi, @@ -223,7 +223,7 @@ function mockVaultMarketConfigReads( }); mockRead(handle, { address: ADDRESSES.publicAllocator, - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "flowCaps", result: [33n, 34n], }); @@ -1171,7 +1171,7 @@ describe("vault fetchers", () => { const handle = createMockClient(mainnet); mockRead(handle, { address: ADDRESSES.publicAllocator, - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "flowCaps", result: [27n, 28n], }); @@ -1527,19 +1527,19 @@ describe("vault fetchers", () => { }); mockRead(handle, { address: ADDRESSES.publicAllocator, - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "admin", result: USER, }); mockRead(handle, { address: ADDRESSES.publicAllocator, - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "fee", result: 56n, }); mockRead(handle, { address: ADDRESSES.publicAllocator, - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "accruedFee", result: 57n, }); diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2.ts index fe17e32d3..9cc775ac5 100644 --- a/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2.ts +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2.ts @@ -325,14 +325,16 @@ export async function fetchVaultV2( let liquidityAdapterIds: Hash[] | undefined; if (hasMorphoVaultV1LiquidityAdapter) liquidityAdapterIds = [ - VaultV2MorphoVaultV1Adapter.adapterId(liquidityAdapter), + VaultV2MorphoVaultV1Adapter.adapterCapId(liquidityAdapter), ]; if (hasMorphoMarketV1AdapterV2LiquidityAdapter) { const marketParams = MarketParams.fromHex(liquidityData); liquidityAdapterIds = [ - VaultV2MorphoMarketV1AdapterV2.adapterId(liquidityAdapter), - VaultV2MorphoMarketV1AdapterV2.collateralId(marketParams.collateralToken), - VaultV2MorphoMarketV1AdapterV2.marketParamsId( + VaultV2MorphoMarketV1AdapterV2.adapterCapId(liquidityAdapter), + VaultV2MorphoMarketV1AdapterV2.collateralCapId( + marketParams.collateralToken, + ), + VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId( liquidityAdapter, marketParams, ), diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BlueMarketPublicAllocatorConfig.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BlueMarketPublicAllocatorConfig.ts new file mode 100644 index 000000000..c18cce788 --- /dev/null +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BlueMarketPublicAllocatorConfig.ts @@ -0,0 +1,82 @@ +import { + getChainAddress, + VaultV2BlueMarketPublicAllocatorConfig, +} from "@morpho-org/blue-sdk"; +import type { Address, Client, Hash } from "viem"; +import { getChainId, readContract } from "viem/actions"; +import { vaultV2BluePublicAllocatorAbi } from "../../abis.js"; +import type { FetchParameters } from "../../types.js"; + +/** + * Fetches BluePublicAllocator permission and cap state for one Vault V2 adapter-market pair. + * + * @param vault - Vault V2 address. + * @param adapter - MorphoMarketV1AdapterV2 address. + * @param adapterMarketCapId - Adapter-scoped market cap id. + * @param client - Viem client used for contract reads. + * @param parameters.account - Optional account passed to viem calls. + * @param parameters.blockNumber - Optional block number for historical reads. + * @param parameters.blockTag - Optional block tag for historical reads. + * @param parameters.stateOverride - Optional viem state override. + * @param parameters.chainId - Optional chain id; defaults to `getChainId(client)`. + * @returns Hydrated adapter-market config with max-in calculation. + * @throws {UnknownAddressError} when the chain has no BluePublicAllocator deployment. + * @throws {UnsupportedChainIdError} when the chain is absent from the address registry. + * @throws {viem.BaseError} when one of the contract reads fails. + * @example + * ```ts + * import type { VaultV2BlueMarketPublicAllocatorConfig } from "@morpho-org/blue-sdk"; + * import { fetchVaultV2BlueMarketPublicAllocatorConfig } from "@morpho-org/blue-sdk-viem"; + * import { type Address, createPublicClient, type Hash, http } from "viem"; + * import { mainnet } from "viem/chains"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }); + * export async function fetchMarketAllocatorConfig( + * vault: Address, + * adapter: Address, + * adapterMarketCapId: Hash, + * ): Promise { + * return fetchVaultV2BlueMarketPublicAllocatorConfig( + * vault, + * adapter, + * adapterMarketCapId, + * client, + * ); + * } + * ``` + */ +// biome-ignore lint/complexity/useMaxParams: follows the package's vault/adapter/id/client/options fetcher convention +export async function fetchVaultV2BlueMarketPublicAllocatorConfig( + vault: Address, + adapter: Address, + adapterMarketCapId: Hash, + client: Client, + parameters: FetchParameters = {}, +): Promise { + const chainId = parameters.chainId ?? (await getChainId(client)); + const allocator = getChainAddress(chainId, "vaultV2BluePublicAllocator"); + const [absoluteCap, canPullFromMarket] = await Promise.all([ + readContract(client, { + ...parameters, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "absoluteCap", + args: [vault, adapterMarketCapId], + }), + readContract(client, { + ...parameters, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "canPullFromMarket", + args: [vault, adapterMarketCapId], + }), + ]); + + return new VaultV2BlueMarketPublicAllocatorConfig({ + vault, + adapter, + adapterMarketCapId, + absoluteCap, + canPullFromMarket, + }); +} diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.integration.test.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.integration.test.ts new file mode 100644 index 000000000..6d9227496 --- /dev/null +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.integration.test.ts @@ -0,0 +1,160 @@ +import { + AccrualVaultV2MorphoMarketV1AdapterV2, + getChainAddress, + VaultV2BlueMarketPublicAllocatorConfig, + VaultV2BluePublicAllocatorConfig, +} from "@morpho-org/blue-sdk"; +import { createViemTest } from "@morpho-org/test/vitest"; +import { encodeFunctionData, parseEther } from "viem"; +import { base } from "viem/chains"; +import { assert, describe, expect } from "vitest"; +import { vaultV2Abi, vaultV2BluePublicAllocatorAbi } from "../../abis.js"; +import { fetchAccrualVaultV2 } from "./VaultV2.js"; +import { fetchVaultV2BluePublicAllocatorData } from "./VaultV2BluePublicAllocatorConfig.js"; + +const vaultV2BluePublicAllocatorTest = createViemTest(base, { + forkUrl: process.env.BASE_RPC_URL, + forkBlockNumber: 50_063_965, // BluePublicAllocator deployment block. + stepsTracing: false, +}); + +describe("Vault V2 BluePublicAllocator fetchers on fork", () => { + vaultV2BluePublicAllocatorTest( + "default: matches direct reads against the deployless query", + async ({ client }) => { + const forkVault = await fetchAccrualVaultV2( + "0x4C7b69b4a82e9E5D8ec60E96516f7A0E17CBC55C", + client, + ); + const forkAdapter = forkVault.accrualAdapters.find( + (candidate) => + candidate instanceof AccrualVaultV2MorphoMarketV1AdapterV2, + ); + assert(forkAdapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2); + + const forkMarket = forkAdapter.markets[0]; + assert(forkMarket != null); + + const allocator = getChainAddress(base.id, "vaultV2BluePublicAllocator"); + const allocatorAccount = await client.readContract({ + address: forkVault.address, + abi: vaultV2Abi, + functionName: "curator", + }); + assert( + await client.readContract({ + address: forkVault.address, + abi: vaultV2Abi, + functionName: "isAllocator", + args: [allocatorAccount], + }), + ); + await client.deal({ + account: allocatorAccount, + amount: parseEther("1"), + }); + const authorizeAllocator = encodeFunctionData({ + abi: vaultV2Abi, + functionName: "setIsAllocator", + args: [allocator, true], + }); + await client.writeContract({ + account: allocatorAccount, + address: forkVault.address, + abi: vaultV2Abi, + functionName: "submit", + args: [authorizeAllocator], + }); + await client.writeContract({ + account: allocatorAccount, + address: forkVault.address, + abi: vaultV2Abi, + functionName: "setIsAllocator", + args: [allocator, true], + }); + const [, , forkAdapterMarketCapId] = forkAdapter.ids(forkMarket.params); + await client.writeContract({ + account: allocatorAccount, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setCanPullFromIdle", + args: [forkVault.address, true], + }); + await client.writeContract({ + account: allocatorAccount, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setPenalty", + args: [forkVault.address, 12n], + }); + await client.writeContract({ + account: allocatorAccount, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setAbsoluteCap", + args: [forkVault.address, forkAdapter.address, forkMarket.params, 500n], + }); + await client.writeContract({ + account: allocatorAccount, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setCanPullFromMarket", + args: [forkVault.address, forkAdapter.address, forkMarket.params, true], + }); + await client.writeContract({ + account: allocatorAccount, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setIsActiveAdapter", + args: [forkVault.address, forkAdapter.address, true], + }); + + const [deployless, direct] = await Promise.all([ + fetchVaultV2BluePublicAllocatorData(forkVault, client, { + deployless: "force", + }), + fetchVaultV2BluePublicAllocatorData(forkVault, client, { + deployless: false, + }), + ]); + + expect(deployless).toStrictEqual(direct); + expect(deployless.publicAllocatorConfig).toBeInstanceOf( + VaultV2BluePublicAllocatorConfig, + ); + expect(deployless.publicAllocatorConfig).toStrictEqual( + new VaultV2BluePublicAllocatorConfig({ + vault: forkVault.address, + canPullFromIdle: true, + penalty: 12n, + }), + ); + expect(deployless.activeAdapters).toStrictEqual( + new Set([forkAdapter.address]), + ); + expect( + deployless.marketPublicAllocatorConfigs[forkAdapterMarketCapId], + ).toBeInstanceOf(VaultV2BlueMarketPublicAllocatorConfig); + expect( + deployless.marketPublicAllocatorConfigs[forkAdapterMarketCapId], + ).toStrictEqual( + new VaultV2BlueMarketPublicAllocatorConfig({ + vault: forkVault.address, + adapter: forkAdapter.address, + adapterMarketCapId: forkAdapterMarketCapId, + absoluteCap: 500n, + canPullFromMarket: true, + }), + ); + expect( + Object.values(deployless.allocations).some( + (allocation) => + allocation != null && + (allocation.absoluteCap > 0n || + allocation.relativeCap > 0n || + allocation.allocation > 0n), + ), + ).toBe(true); + }, + ); +}); diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.test.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.test.ts new file mode 100644 index 000000000..aafabe4ff --- /dev/null +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.test.ts @@ -0,0 +1,316 @@ +import { + AccrualVaultV2, + AccrualVaultV2MorphoMarketV1AdapterV2, + getChainAddress, + Market, + MarketParams, + MathLib, + VaultV2BlueMarketPublicAllocatorConfig, + VaultV2BluePublicAllocatorConfig, +} from "@morpho-org/blue-sdk"; +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import type { Address } from "viem"; +import { zeroAddress } from "viem"; +import { mainnet } from "viem/chains"; +import { describe, expect, test } from "vitest"; +import { + mockDeploylessRead, + mockDeploylessReads, +} from "../../__test__/viem.js"; +import { vaultV2Abi, vaultV2BluePublicAllocatorAbi } from "../../abis.js"; +import { abi as queryAbi } from "../../queries/vault-v2/GetVaultV2BluePublicAllocatorConfig.js"; +import { fetchVaultV2BlueMarketPublicAllocatorConfig } from "./VaultV2BlueMarketPublicAllocatorConfig.js"; +import { + fetchVaultV2BluePublicAllocatorConfig, + fetchVaultV2BluePublicAllocatorData, +} from "./VaultV2BluePublicAllocatorConfig.js"; + +const ALLOCATOR = getChainAddress(mainnet.id, "vaultV2BluePublicAllocator"); +const VAULT: Address = "0x0000000000000000000000000000000000000002"; +const ADAPTER: Address = "0x0000000000000000000000000000000000000003"; +const ASSET: Address = "0x0000000000000000000000000000000000000004"; +const IRM: Address = "0x0000000000000000000000000000000000000005"; + +const marketParams = new MarketParams({ + loanToken: ASSET, + collateralToken: "0x0000000000000000000000000000000000000006", + oracle: "0x0000000000000000000000000000000000000007", + irm: IRM, + lltv: 860_000_000_000_000_000n, +}); +const market = new Market({ + params: marketParams, + totalSupplyAssets: 100n, + totalBorrowAssets: 0n, + totalSupplyShares: 100_000_000n, + totalBorrowShares: 0n, + lastUpdate: 1n, + fee: 0n, +}); +const adapter = new AccrualVaultV2MorphoMarketV1AdapterV2( + { + address: ADAPTER, + parentVault: VAULT, + skimRecipient: zeroAddress, + marketIds: [market.id], + adaptiveCurveIrm: IRM, + supplyShares: { [market.id]: market.totalSupplyShares }, + }, + [market], +); +const vault = new AccrualVaultV2( + { + address: VAULT, + asset: ASSET, + _totalAssets: 100n, + totalSupply: 100n, + virtualShares: 0n, + maxRate: 0n, + lastUpdate: 1n, + liquidityAdapter: zeroAddress, + liquidityData: "0x", + liquidityAllocations: undefined, + performanceFee: 0n, + managementFee: 0n, + performanceFeeRecipient: zeroAddress, + managementFeeRecipient: zeroAddress, + }, + undefined, + [adapter], + 0n, + {}, +); +const unallocatedTargetAdapter = new AccrualVaultV2MorphoMarketV1AdapterV2( + { + address: ADAPTER, + parentVault: VAULT, + skimRecipient: zeroAddress, + marketIds: [], + adaptiveCurveIrm: IRM, + supplyShares: {}, + }, + [], +); +const unallocatedTargetVault = new AccrualVaultV2( + { + ...vault, + liquidityAllocations: vault.liquidityAllocations?.map((allocation) => ({ + ...allocation, + })), + }, + undefined, + [unallocatedTargetAdapter], + vault.assetBalance, + { ...vault.forceDeallocatePenalties }, +); +const ids = adapter.ids(marketParams); +const [, , adapterMarketCapId] = ids; + +const expected = { + publicAllocatorConfig: new VaultV2BluePublicAllocatorConfig({ + vault: VAULT, + canPullFromIdle: true, + penalty: 12n, + }), + activeAdapters: new Set([ADAPTER]), + marketPublicAllocatorConfigs: { + [adapterMarketCapId]: new VaultV2BlueMarketPublicAllocatorConfig({ + vault: VAULT, + adapter: ADAPTER, + adapterMarketCapId, + absoluteCap: 500n, + canPullFromMarket: true, + }), + }, + allocations: Object.fromEntries( + ids.map((id) => [ + id, + { + id, + absoluteCap: 1_000n, + relativeCap: MathLib.WAD, + allocation: 100n, + }, + ]), + ), +}; + +const mockDirectReads = ( + handle: ReturnType, + isActiveAdapter = true, +) => { + mockRead(handle, { + address: VAULT, + abi: vaultV2Abi, + functionName: "isAllocator", + result: true, + }); + mockRead(handle, { + address: ALLOCATOR, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "vaultData", + result: [true, 12n], + }); + mockRead(handle, { + address: ALLOCATOR, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "absoluteCap", + result: 500n, + }); + mockRead(handle, { + address: ALLOCATOR, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "canPullFromMarket", + result: true, + }); + mockRead(handle, { + address: ALLOCATOR, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "isActiveAdapter", + result: isActiveAdapter, + }); + mockRead(handle, { + address: VAULT, + abi: vaultV2Abi, + functionName: "absoluteCap", + result: 1_000n, + }); + mockRead(handle, { + address: VAULT, + abi: vaultV2Abi, + functionName: "relativeCap", + result: MathLib.WAD, + }); + mockRead(handle, { + address: VAULT, + abi: vaultV2Abi, + functionName: "allocation", + result: 100n, + }); +}; + +describe("Vault V2 BluePublicAllocator fetchers", () => { + test("default: leaf fetchers use the chain allocator", async () => { + const handle = createMockClient(mainnet); + mockDirectReads(handle); + + const config = await fetchVaultV2BluePublicAllocatorConfig( + VAULT, + handle.client, + ); + expect(config).toBeInstanceOf(VaultV2BluePublicAllocatorConfig); + expect(config).toStrictEqual(expected.publicAllocatorConfig); + const marketConfig = await fetchVaultV2BlueMarketPublicAllocatorConfig( + VAULT, + ADAPTER, + adapterMarketCapId, + handle.client, + ); + expect(marketConfig).toBeInstanceOf(VaultV2BlueMarketPublicAllocatorConfig); + expect(marketConfig).toStrictEqual( + expected.marketPublicAllocatorConfigs[adapterMarketCapId], + ); + }); + + test("behavior: deployless batching includes an unallocated target", async () => { + const handle = createMockClient(mainnet); + mockDeploylessRead(handle, queryAbi, "query", { + isAllocator: true, + canPullFromIdle: true, + penalty: 12n, + isActiveAdapters: [true], + marketConfigs: [ + { + adapter: ADAPTER, + adapterMarketCapId, + absoluteCap: 500n, + canPullFromMarket: true, + }, + ], + allocations: ids.map((id) => ({ + id, + absoluteCap: 1_000n, + relativeCap: MathLib.WAD, + allocation: 100n, + })), + }); + + await expect( + fetchVaultV2BluePublicAllocatorData( + unallocatedTargetVault, + handle.client, + { targetMarketParams: marketParams }, + ), + ).resolves.toStrictEqual(expected); + }); + + test("behavior: direct-read fallback includes an unallocated target", async () => { + const handle = createMockClient(mainnet); + mockDeploylessReads(handle, [new Error("deployless unavailable")]); + mockDirectReads(handle); + + await expect( + fetchVaultV2BluePublicAllocatorData( + unallocatedTargetVault, + handle.client, + { targetMarketParams: marketParams }, + ), + ).resolves.toStrictEqual(expected); + }); + + test("behavior: deployless batching omits config when the allocator is unauthorized", async () => { + const handle = createMockClient(mainnet); + mockDeploylessRead(handle, queryAbi, "query", { + isAllocator: false, + canPullFromIdle: true, + penalty: 12n, + isActiveAdapters: [true], + marketConfigs: [], + allocations: [], + }); + + await expect( + fetchVaultV2BluePublicAllocatorData(vault, handle.client), + ).resolves.toMatchObject({ publicAllocatorConfig: undefined }); + }); + + test("behavior: direct-read fallback omits config when the allocator is unauthorized", async () => { + const handle = createMockClient(mainnet); + mockDeploylessReads(handle, [new Error("deployless unavailable")]); + mockDirectReads(handle); + mockRead(handle, { + address: VAULT, + abi: vaultV2Abi, + functionName: "isAllocator", + result: false, + }); + + await expect( + fetchVaultV2BluePublicAllocatorData(vault, handle.client), + ).resolves.toMatchObject({ publicAllocatorConfig: undefined }); + }); + + test("error: forced deployless failure does not fall back", async () => { + const handle = createMockClient(mainnet); + mockDeploylessReads(handle, [new Error("deployless unavailable")]); + + await expect( + fetchVaultV2BluePublicAllocatorData(vault, handle.client, { + deployless: "force", + }), + ).rejects.toThrow(); + }); + + test("behavior: omits inactive adapters from the registry", async () => { + const handle = createMockClient(mainnet); + mockDeploylessReads(handle, [new Error("deployless unavailable")]); + mockDirectReads(handle, false); + + const result = await fetchVaultV2BluePublicAllocatorData( + vault, + handle.client, + ); + + expect(result.activeAdapters).toStrictEqual(new Set()); + }); +}); diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.ts new file mode 100644 index 000000000..f271272f7 --- /dev/null +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/VaultV2BluePublicAllocatorConfig.ts @@ -0,0 +1,307 @@ +import { + type AccrualVaultV2, + AccrualVaultV2MorphoMarketV1AdapterV2, + getChainAddress, + type IVaultV2Allocation, + type MarketParams, + VaultV2BlueMarketPublicAllocatorConfig, + VaultV2BluePublicAllocatorConfig, +} from "@morpho-org/blue-sdk"; +import type { Address, Client, Hash } from "viem"; +import { getChainId, readContract } from "viem/actions"; +import { vaultV2Abi, vaultV2BluePublicAllocatorAbi } from "../../abis.js"; +import { + abi, + code, +} from "../../queries/vault-v2/GetVaultV2BluePublicAllocatorConfig.js"; +import type { + DeploylessFetchParameters, + FetchParameters, +} from "../../types.js"; +import { fetchVaultV2BlueMarketPublicAllocatorConfig } from "./VaultV2BlueMarketPublicAllocatorConfig.js"; + +/** + * Fetches a Vault V2's BluePublicAllocator-wide configuration. + * + * @param vault - Vault V2 address. + * @param client - Viem client used for the contract read. + * @param parameters.account - Optional account passed to viem calls. + * @param parameters.blockNumber - Optional block number for historical reads. + * @param parameters.blockTag - Optional block tag for historical reads. + * @param parameters.stateOverride - Optional viem state override. + * @param parameters.chainId - Optional chain id; defaults to `getChainId(client)`. + * @returns Hydrated vault allocator configuration with penalty calculations. + * @throws {UnknownAddressError} when the chain has no BluePublicAllocator deployment. + * @throws {UnsupportedChainIdError} when the chain is absent from the address registry. + * @throws {viem.BaseError} when the contract read fails. + * @example + * ```ts + * import type { VaultV2BluePublicAllocatorConfig } from "@morpho-org/blue-sdk"; + * import { fetchVaultV2BluePublicAllocatorConfig } from "@morpho-org/blue-sdk-viem"; + * import { type Address, createPublicClient, http } from "viem"; + * import { mainnet } from "viem/chains"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }); + * export async function fetchAllocatorConfig( + * vault: Address, + * ): Promise { + * return fetchVaultV2BluePublicAllocatorConfig(vault, client); + * } + * ``` + */ +// biome-ignore lint/complexity/useMaxParams: follows the package's address/client/options fetcher convention +export async function fetchVaultV2BluePublicAllocatorConfig( + vault: Address, + client: Client, + parameters: FetchParameters = {}, +): Promise { + const chainId = parameters.chainId ?? (await getChainId(client)); + const allocator = getChainAddress(chainId, "vaultV2BluePublicAllocator"); + const [canPullFromIdle, penalty] = await readContract(client, { + ...parameters, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "vaultData", + args: [vault], + }); + + return new VaultV2BluePublicAllocatorConfig({ + vault, + canPullFromIdle, + penalty, + }); +} + +/** + * Fetches all BluePublicAllocator and Vault V2 cap data needed to simulate + * reallocations for one hydrated Vault V2. + * + * Only `VaultV2MorphoMarketV1AdapterV2` adapters participate. The function + * derives every adapter-market id and shared vault allocation id from the + * hydrated vault, uses one deployless `eth_call` by default, and falls back to + * direct reads unless deployless mode is forced. + * + * @param vault - Hydrated Vault V2 whose accrued adapters provide the candidate markets. + * @param client - Viem client used for deployless or direct reads. + * @param parameters.account - Optional account passed to viem calls. + * @param parameters.blockNumber - Optional block number for historical reads. + * @param parameters.blockTag - Optional block tag for historical reads. + * @param parameters.stateOverride - Optional viem state override. + * @param parameters.chainId - Optional chain id; defaults to `getChainId(client)`. + * @param parameters.deployless - Deployless mode; defaults to `true`, with direct-read fallback. + * @param parameters.targetMarketParams - Optional target market whose config and cap ids are fetched even when the adapter has no current position. + * @returns Vault-wide config when the BluePublicAllocator is authorized, active-adapter set, adapter-market configs keyed by `adapterMarketCapId`, and allocations keyed by derived id. + * @throws {UnknownAddressError} when the chain has no BluePublicAllocator deployment. + * @throws {UnsupportedChainIdError} when the chain is absent from the address registry. + * @throws {viem.BaseError} when deployless mode is forced and fails, or when a direct contract read fails. + * @example + * ```ts + * import type { AccrualVaultV2 } from "@morpho-org/blue-sdk"; + * import { fetchVaultV2BluePublicAllocatorData } from "@morpho-org/blue-sdk-viem"; + * import { createPublicClient, http } from "viem"; + * import { mainnet } from "viem/chains"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }); + * export async function fetchAllocatorData( + * vault: AccrualVaultV2, + * ) { + * const data = await fetchVaultV2BluePublicAllocatorData(vault, client); + * // data contains publicAllocatorConfig, activeAdapters, marketPublicAllocatorConfigs, and allocations. + * return data; + * } + * ``` + */ +// biome-ignore lint/complexity/useMaxParams: follows the package's entity/client/options fetcher convention +export async function fetchVaultV2BluePublicAllocatorData( + vault: AccrualVaultV2, + client: Client, + { + deployless = true, + targetMarketParams, + ...parameters + }: DeploylessFetchParameters & { + readonly targetMarketParams?: MarketParams; + } = {}, +) { + const chainId = parameters.chainId ?? (await getChainId(client)); + const allocator = getChainAddress(chainId, "vaultV2BluePublicAllocator"); + const marketRequests: { + readonly adapter: Address; + readonly adapterMarketCapId: Hash; + }[] = []; + const adapters = new Set
(); + const allocationIds = new Set(); + + for (const adapter of vault.accrualAdapters) { + if (!(adapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2)) continue; + adapters.add(adapter.address); + + const marketParamsList = adapter.markets.map((market) => market.params); + if ( + targetMarketParams != null && + !marketParamsList.some( + (marketParams) => marketParams.id === targetMarketParams.id, + ) + ) + marketParamsList.push(targetMarketParams); + + for (const marketParams of marketParamsList) { + const ids = adapter.ids(marketParams); + const [, , adapterMarketCapId] = ids; + marketRequests.push({ + adapter: adapter.address, + adapterMarketCapId, + }); + for (const id of ids) allocationIds.add(id); + } + } + + const adapterList = [...adapters]; + const allocationIdList = [...allocationIds]; + + if (deployless) { + try { + const result = await readContract(client, { + ...parameters, + abi, + code, + functionName: "query", + args: [ + allocator, + vault.address, + adapterList, + marketRequests, + allocationIdList, + ], + }); + + const marketPublicAllocatorConfigs: Record< + Hash, + VaultV2BlueMarketPublicAllocatorConfig | undefined + > = {}; + for (const config of result.marketConfigs) { + marketPublicAllocatorConfigs[config.adapterMarketCapId] = + new VaultV2BlueMarketPublicAllocatorConfig({ + vault: vault.address, + ...config, + }); + } + + const allocations: Record = {}; + for (const allocation of result.allocations) { + allocations[allocation.id] = allocation; + } + + return { + publicAllocatorConfig: result.isAllocator + ? new VaultV2BluePublicAllocatorConfig({ + vault: vault.address, + canPullFromIdle: result.canPullFromIdle, + penalty: result.penalty, + }) + : undefined, + activeAdapters: new Set( + adapterList.filter((_, index) => result.isActiveAdapters[index]), + ), + marketPublicAllocatorConfigs, + allocations, + }; + } catch (error) { + if (deployless === "force") throw error; + // Fall back to direct reads when deployless execution is unavailable. + } + } + + const [ + isAllocator, + publicAllocatorConfig, + isActiveAdapters, + marketConfigs, + allocationValues, + ] = await Promise.all([ + readContract(client, { + ...parameters, + address: vault.address, + abi: vaultV2Abi, + functionName: "isAllocator", + args: [allocator], + }), + fetchVaultV2BluePublicAllocatorConfig(vault.address, client, { + ...parameters, + chainId, + }), + Promise.all( + adapterList.map((adapter) => + readContract(client, { + ...parameters, + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "isActiveAdapter", + args: [vault.address, adapter], + }), + ), + ), + Promise.all( + marketRequests.map(({ adapter, adapterMarketCapId }) => + fetchVaultV2BlueMarketPublicAllocatorConfig( + vault.address, + adapter, + adapterMarketCapId, + client, + { ...parameters, chainId }, + ), + ), + ), + Promise.all( + allocationIdList.map(async (id) => { + const [absoluteCap, relativeCap, allocation] = await Promise.all([ + readContract(client, { + ...parameters, + address: vault.address, + abi: vaultV2Abi, + functionName: "absoluteCap", + args: [id], + }), + readContract(client, { + ...parameters, + address: vault.address, + abi: vaultV2Abi, + functionName: "relativeCap", + args: [id], + }), + readContract(client, { + ...parameters, + address: vault.address, + abi: vaultV2Abi, + functionName: "allocation", + args: [id], + }), + ]); + + return { id, absoluteCap, relativeCap, allocation }; + }), + ), + ]); + + const marketPublicAllocatorConfigs: Record< + Hash, + VaultV2BlueMarketPublicAllocatorConfig | undefined + > = {}; + for (const config of marketConfigs) { + marketPublicAllocatorConfigs[config.adapterMarketCapId] = config; + } + + const allocations: Record = {}; + for (const allocation of allocationValues) { + allocations[allocation.id] = allocation; + } + + return { + publicAllocatorConfig: isAllocator ? publicAllocatorConfig : undefined, + activeAdapters: new Set( + adapterList.filter((_, index) => isActiveAdapters[index]), + ), + marketPublicAllocatorConfigs, + allocations, + }; +} diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/index.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/index.ts index dc5d27071..245470ada 100644 --- a/packages/blue-sdk-viem/src/fetch/vault-v2/index.ts +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/index.ts @@ -1,5 +1,7 @@ export * from "./VaultV2.js"; export * from "./VaultV2Adapter.js"; +export * from "./VaultV2BlueMarketPublicAllocatorConfig.js"; +export * from "./VaultV2BluePublicAllocatorConfig.js"; export * from "./VaultV2MorphoMarketV1Adapter.js"; export * from "./VaultV2MorphoMarketV1AdapterV2.js"; export * from "./VaultV2MorphoVaultV1Adapter.js"; diff --git a/packages/blue-sdk-viem/src/fetch/vault-v2/vault-v2.test.ts b/packages/blue-sdk-viem/src/fetch/vault-v2/vault-v2.test.ts index 3462e89c1..405a7e0ed 100644 --- a/packages/blue-sdk-viem/src/fetch/vault-v2/vault-v2.test.ts +++ b/packages/blue-sdk-viem/src/fetch/vault-v2/vault-v2.test.ts @@ -138,7 +138,7 @@ const vaultV2Result = { isLiquidityAdapterKnown: true, liquidityAllocations: [ { - id: VaultV2MorphoVaultV1Adapter.adapterId(ADAPTER), + id: VaultV2MorphoVaultV1Adapter.adapterCapId(ADAPTER), absoluteCap: 1_000n, relativeCap: 1_000000000000000000n, allocation: 100n, @@ -252,6 +252,7 @@ function mockVaultV2AllocationReads( handle: ReturnType, ids: readonly `0x${string}`[], ) { + const [adapterCapId] = ids; for (const id of ids) { mockRead(handle, { address: VAULT, @@ -269,7 +270,7 @@ function mockVaultV2AllocationReads( address: VAULT, abi: vaultV2Abi, functionName: "allocation", - result: id === ids[0] ? 100n : 0n, + result: id === adapterCapId ? 100n : 0n, }); } } @@ -357,7 +358,7 @@ describe("fetchVaultV2", () => { result: false, }); mockVaultV2AllocationReads(handle, [ - VaultV2MorphoVaultV1Adapter.adapterId(ADAPTER), + VaultV2MorphoVaultV1Adapter.adapterCapId(ADAPTER), ]); const vault = await fetchVaultV2(VAULT, handle.client, { @@ -366,7 +367,7 @@ describe("fetchVaultV2", () => { expect(vault.liquidityAllocations).toHaveLength(1); expect(vault.liquidityAllocations?.[0]?.id).toBe( - VaultV2MorphoVaultV1Adapter.adapterId(ADAPTER), + VaultV2MorphoVaultV1Adapter.adapterCapId(ADAPTER), ); expect(vault.performanceFeeRecipientCanReceiveShares).toBe(false); expect(vault.managementFeeRecipientCanReceiveShares).toBe(false); @@ -542,9 +543,9 @@ describe("fetchVaultV2", () => { result: true, }); mockVaultV2AllocationReads(handle, [ - VaultV2MorphoMarketV1AdapterV2.adapterId(ADAPTER), - VaultV2MorphoMarketV1AdapterV2.collateralId(COLLATERAL), - VaultV2MorphoMarketV1AdapterV2.marketParamsId(ADAPTER, MARKET_PARAMS), + VaultV2MorphoMarketV1AdapterV2.adapterCapId(ADAPTER), + VaultV2MorphoMarketV1AdapterV2.collateralCapId(COLLATERAL), + VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId(ADAPTER, MARKET_PARAMS), ]); const vault = await fetchVaultV2(VAULT, handle.client, { @@ -1816,7 +1817,7 @@ const accrualVaultV2Result = { isLiquidityAdapterKnown: true, liquidityAllocations: [ { - id: VaultV2MorphoMarketV1AdapterV2.adapterId(ADAPTER), + id: VaultV2MorphoMarketV1AdapterV2.adapterCapId(ADAPTER), absoluteCap: 1_000n, relativeCap: 1_000000000000000000n, allocation: 100n, diff --git a/packages/blue-sdk-viem/src/queries/vault-v2/GetVaultV2BluePublicAllocatorConfig.ts b/packages/blue-sdk-viem/src/queries/vault-v2/GetVaultV2BluePublicAllocatorConfig.ts new file mode 100644 index 000000000..dbe591004 --- /dev/null +++ b/packages/blue-sdk-viem/src/queries/vault-v2/GetVaultV2BluePublicAllocatorConfig.ts @@ -0,0 +1,134 @@ +/** @internal Deployless `GetVaultV2BluePublicAllocatorConfig` query ABI. */ +export const abi = [ + { + inputs: [ + { + internalType: "contract IBluePublicAllocator", + name: "allocator", + type: "address", + }, + { + internalType: "contract IVaultV2", + name: "vault", + type: "address", + }, + { + internalType: "address[]", + name: "adapters", + type: "address[]", + }, + { + components: [ + { + internalType: "address", + name: "adapter", + type: "address", + }, + { + internalType: "bytes32", + name: "adapterMarketCapId", + type: "bytes32", + }, + ], + internalType: "struct VaultV2BlueMarketPublicAllocatorRequest[]", + name: "marketRequests", + type: "tuple[]", + }, + { + internalType: "bytes32[]", + name: "allocationIds", + type: "bytes32[]", + }, + ], + name: "query", + outputs: [ + { + components: [ + { + internalType: "bool", + name: "isAllocator", + type: "bool", + }, + { + internalType: "bool", + name: "canPullFromIdle", + type: "bool", + }, + { + internalType: "uint64", + name: "penalty", + type: "uint64", + }, + { + internalType: "bool[]", + name: "isActiveAdapters", + type: "bool[]", + }, + { + components: [ + { + internalType: "address", + name: "adapter", + type: "address", + }, + { + internalType: "bytes32", + name: "adapterMarketCapId", + type: "bytes32", + }, + { + internalType: "uint256", + name: "absoluteCap", + type: "uint256", + }, + { + internalType: "bool", + name: "canPullFromMarket", + type: "bool", + }, + ], + internalType: "struct VaultV2BlueMarketPublicAllocatorResponse[]", + name: "marketConfigs", + type: "tuple[]", + }, + { + components: [ + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + { + internalType: "uint256", + name: "absoluteCap", + type: "uint256", + }, + { + internalType: "uint256", + name: "relativeCap", + type: "uint256", + }, + { + internalType: "uint256", + name: "allocation", + type: "uint256", + }, + ], + internalType: "struct VaultV2AllocationResponse[]", + name: "allocations", + type: "tuple[]", + }, + ], + internalType: "struct VaultV2BluePublicAllocatorResponse", + name: "res", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +/** @internal Deployless `GetVaultV2BluePublicAllocatorConfig` query bytecode. */ +export const code = + "0x60808060405234601557610980908161001a8239f35b5f80fdfe60806040526004361015610011575f80fd5b5f3560e01c6352ae457214610024575f80fd5b346103615760a0366003190112610361576004356001600160a01b038116810361036157602435906001600160a01b03821682036103615760443567ffffffffffffffff81116103615761007c903690600401610846565b919067ffffffffffffffff60643511610361573660236064350112156103615767ffffffffffffffff6064356004013511610361573660246064356004013560061b6064350101116103615760843567ffffffffffffffff8111610361576100e8903690600401610846565b93909161014060408190525f608081905260a081905260c0908152606060e0819052610100819052610120526326f6f90760e11b82526001600160a01b03868116600484015290939190602090829060249082908c165afa90811561036d575f916107f8575b50151560805260408051636b97fbcd60e11b81526001600160a01b038981166004830152909190829060249082908a165afa801561036d575f915f9161079d575b5067ffffffffffffffff168452151560a0526101aa816108c2565b6101b76040519182610893565b818152601f196101c6836108c2565b0136602083013760e0525f5b8181106106f1575050506101eb606435600401356108c2565b6101f86040519182610893565b60046064350135808252601f199061020f906108c2565b015f5b8181106106da575050610100525f5b606435600401358110156103a9578060061b6064350190610244602483016108fe565b60405163011f009b60e31b81526001600160a01b038981166004830152604485810135602484015260209183919082908b165afa90811561036d575f91610378575b506040516369f1e26b60e01b81526001600160a01b038a81166004830152604486810135602484015291929160209184919082908c165afa91821561036d575f9261031f575b50918391610318936044600197604051946102e686610877565b898060a01b03168552013560208401526040830152151560608201526080800151906103128383610912565b52610912565b5001610221565b9150916020823d8211610365575b8161033a60209383610893565b810103126103615760019461031893604461035587956108b5565b945050919350946102cc565b5f80fd5b3d915061032d565b6040513d5f823e3d90fd5b90506020813d82116103a1575b8161039260209383610893565b8101031261036157515f610286565b3d9150610385565b5084846103b5816108c2565b6103c26040519182610893565b818152601f196103d1836108c2565b015f5b8181106106c3575050610120525f5b81811061053b578360405180916020825267ffffffffffffffff60e08301916080511515602085015260206080015115156040850152511660608301526060608001519060c060808401528151809152602061010084019201905f5b81811061052057505061010051838303601f190160a0850152805180845260209384019390910191505f5b8181106104dd57505061012051838303601f190160c0850152805180845260209384019390910191505f5b8181106104a3575050500390f35b9193509160206080600192606087518051835284810151858401526040810151604084015201516060820152019401910191849392610495565b919350916020608060019260608751858060a01b03815116835284810151858401526040810151604084015201511515606082015201940191019184939261046a565b8251151584528594506020938401939092019160010161043f565b6105468183876108da565b604051632f0374dd60e21b81529035600482018190529091906020836024816001600160a01b0389165afa92831561036d575f93610690575b5060405163a68bafa360e01b8152600481018290526020816024816001600160a01b038a165afa90811561036d575f9161065f575b5060405163c69507dd60e01b815260048101839052906020826024816001600160a01b038b165afa91821561036d575f92610629575b50918391610622936001966040519361060285610877565b845260208401526040830152606082015261012051906103128383610912565b50016103e3565b9150916020823d8211610657575b8161064460209383610893565b81010312610361579051909160016105ea565b3d9150610637565b90506020813d8211610688575b8161067960209383610893565b810103126103615751886105b4565b3d915061066c565b9092506020813d82116106bb575b816106ab60209383610893565b810103126103615751918761057f565b3d915061069e565b6020906106ce610926565b828286010152016103d4565b6020906106e5610926565b82828601015201610212565b6107046106ff8284866108da565b6108fe565b6040516366faa83960e01b81526001600160a01b038a8116600483015291821660248201529190602090839060449082908b165afa801561036d575f90610764575b6001925061075982606060800151610912565b9015159052016101d2565b506020823d8211610795575b8161077d60209383610893565b81010312610361576107906001926108b5565b610746565b3d9150610770565b9150506040813d6040116107f0575b816107b960409383610893565b810103126103615760206107cc826108b5565b9101519067ffffffffffffffff82168203610361579067ffffffffffffffff61018f565b3d91506107ac565b90506020813d60201161082a575b8161081360209383610893565b8101031261036157610824906108b5565b5f61014e565b3d9150610806565b634e487b7160e01b5f52604160045260245ffd5b9181601f840112156103615782359167ffffffffffffffff8311610361576020808501948460051b01011161036157565b6080810190811067ffffffffffffffff82111761083257604052565b90601f8019910116810190811067ffffffffffffffff82111761083257604052565b5190811515820361036157565b67ffffffffffffffff81116108325760051b60200190565b91908110156108ea5760051b0190565b634e487b7160e01b5f52603260045260245ffd5b356001600160a01b03811681036103615790565b80518210156108ea5760209160051b010190565b6040519061093382610877565b5f606083828152826020820152826040820152015256fea2646970667358221220e2386f7e83e0e06c5b5e8265f5ee8d981ceb81f18cbd7cec8c547c9462c3140164736f6c63430008240033"; diff --git a/packages/blue-sdk-viem/test/Vault.test.ts b/packages/blue-sdk-viem/test/Vault.test.ts index 5cd62a19c..8a6004be2 100644 --- a/packages/blue-sdk-viem/test/Vault.test.ts +++ b/packages/blue-sdk-viem/test/Vault.test.ts @@ -8,7 +8,7 @@ import { vaults } from "@morpho-org/morpho-test"; import { zeroAddress, zeroHash } from "viem"; import { describe, expect } from "vitest"; import { Vault } from "../src/augment/Vault.js"; -import { metaMorphoAbi, publicAllocatorAbi } from "../src/index.js"; +import { metaMorphoAbi, vaultV1PublicAllocatorAbi } from "../src/index.js"; import { test, test2 } from "./setup.js"; const { steakUsdc, steakPaxg } = vaults[ChainId.EthMainnet]; @@ -27,12 +27,15 @@ describe("augment/Vault", () => { address: steakUsdc.address, abi: metaMorphoAbi, functionName: "setIsAllocator", - args: [addressesRegistry[ChainId.EthMainnet].publicAllocator, true], + args: [ + addressesRegistry[ChainId.EthMainnet].vaultV1PublicAllocator, + true, + ], }); await client.writeContract({ account: owner, - address: addressesRegistry[ChainId.EthMainnet].publicAllocator, - abi: publicAllocatorAbi, + address: addressesRegistry[ChainId.EthMainnet].vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "setFee", args: [steakUsdc.address, 1n], }); diff --git a/packages/blue-sdk-viem/test/VaultMarketConfig.test.ts b/packages/blue-sdk-viem/test/VaultMarketConfig.test.ts index 7f1aab91c..b26f1d285 100644 --- a/packages/blue-sdk-viem/test/VaultMarketConfig.test.ts +++ b/packages/blue-sdk-viem/test/VaultMarketConfig.test.ts @@ -6,7 +6,7 @@ import { import { markets, vaults } from "@morpho-org/morpho-test"; import { describe, expect } from "vitest"; import { VaultMarketConfig } from "../src/augment/VaultMarketConfig.js"; -import { metaMorphoAbi, publicAllocatorAbi } from "../src/index.js"; +import { metaMorphoAbi, vaultV1PublicAllocatorAbi } from "../src/index.js"; import { test } from "./setup.js"; const { usdc_wstEth } = markets[ChainId.EthMainnet]; @@ -26,19 +26,22 @@ describe("augment/VaultMarketConfig", () => { address: steakUsdc.address, abi: metaMorphoAbi, functionName: "setIsAllocator", - args: [addressesRegistry[ChainId.EthMainnet].publicAllocator, true], + args: [ + addressesRegistry[ChainId.EthMainnet].vaultV1PublicAllocator, + true, + ], }); await client.writeContract({ account: owner, - address: addressesRegistry[ChainId.EthMainnet].publicAllocator, - abi: publicAllocatorAbi, + address: addressesRegistry[ChainId.EthMainnet].vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "setFee", args: [steakUsdc.address, 1n], }); await client.writeContract({ account: owner, - address: addressesRegistry[ChainId.EthMainnet].publicAllocator, - abi: publicAllocatorAbi, + address: addressesRegistry[ChainId.EthMainnet].vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "setFlowCaps", args: [ steakUsdc.address, diff --git a/packages/blue-sdk-viem/test/VaultV2.test.ts b/packages/blue-sdk-viem/test/VaultV2.test.ts index 6a52f49db..e385384fe 100644 --- a/packages/blue-sdk-viem/test/VaultV2.test.ts +++ b/packages/blue-sdk-viem/test/VaultV2.test.ts @@ -112,7 +112,7 @@ describe("AccrualVaultV2", () => { liquidityData: "0x", liquidityAllocations: [ { - id: VaultV2MorphoVaultV1Adapter.adapterId( + id: VaultV2MorphoVaultV1Adapter.adapterCapId( "0x2C32fF5E1d976015AdbeA8cC73c7Da3A6677C25F", ), absoluteCap: 1000000000000n, @@ -150,7 +150,7 @@ describe("AccrualVaultV2", () => { liquidityData: "0x", liquidityAllocations: [ { - id: VaultV2MorphoVaultV1Adapter.adapterId( + id: VaultV2MorphoVaultV1Adapter.adapterCapId( "0x2C32fF5E1d976015AdbeA8cC73c7Da3A6677C25F", ), absoluteCap: 1000000000000n, diff --git a/packages/blue-sdk-viem/test/VaultV2Adapter.test.ts b/packages/blue-sdk-viem/test/VaultV2Adapter.test.ts index a9c2e4cb7..843a98082 100644 --- a/packages/blue-sdk-viem/test/VaultV2Adapter.test.ts +++ b/packages/blue-sdk-viem/test/VaultV2Adapter.test.ts @@ -212,7 +212,9 @@ describe("LiquidityAdapter vaultV1", () => { abi: vaultV2Abi, functionName: "absoluteCap", args: [ - VaultV2MorphoVaultV1Adapter.adapterId(vaultV2AdapterVaultV1Address), + VaultV2MorphoVaultV1Adapter.adapterCapId( + vaultV2AdapterVaultV1Address, + ), ], }), readContract(client, { @@ -220,7 +222,9 @@ describe("LiquidityAdapter vaultV1", () => { abi: vaultV2Abi, functionName: "allocation", args: [ - VaultV2MorphoVaultV1Adapter.adapterId(vaultV2AdapterVaultV1Address), + VaultV2MorphoVaultV1Adapter.adapterCapId( + vaultV2AdapterVaultV1Address, + ), ], }), ]); diff --git a/packages/blue-sdk/AGENTS.md b/packages/blue-sdk/AGENTS.md index 31e1f3f49..ce0cc7aae 100644 --- a/packages/blue-sdk/AGENTS.md +++ b/packages/blue-sdk/AGENTS.md @@ -10,6 +10,9 @@ - Use `_try(accessor, UnknownError)` for optional domain lookups, not broad `catch`. - Protocol entity folders (`market/`, `vault/`, `token/`, `position/`, `holding/`, `user/`) own their classes and folder barrels. - Getters may throw typed `Unknown*Error`; nullable lookup paths should use `_try` or `tryGet*`-style helpers deliberately. +- Vault V2 absolute/relative allocation-cap math is canonical in `VaultV2Utils.allocationHeadroom`; consumers such as `AccrualVaultV2.maxDeposit` and shared-liquidity simulation delegate to it. +- Vault V2 BluePublicAllocator configs are hydrated classes with plain-object-compatible `I*` inputs and math delegated to dedicated `*Utils` namespaces. Vault-wide config computes penalties; market config computes max-in capacity from its absolute cap and effective allocation. The allocator is canonical per chain and comes from the address registry; adapter activation is normalized separately as a vault-keyed set of adapter addresses. +- `marketParamsAbi` is owned by `@morpho-org/morpho-ts/abis` and re-exported from `MarketParams.ts` for backward compatibility; do not define a second copy in this package. ## Continuous Improvement diff --git a/packages/blue-sdk/package.json b/packages/blue-sdk/package.json index 75b17b981..89e2b014e 100644 --- a/packages/blue-sdk/package.json +++ b/packages/blue-sdk/package.json @@ -33,7 +33,7 @@ "@noble/hashes": "^2.2.0" }, "peerDependencies": { - "@morpho-org/morpho-ts": "^2.7.0" + "@morpho-org/morpho-ts": "^2.10.0" }, "devDependencies": { "@morpho-org/morpho-ts": "workspace:^", diff --git a/packages/blue-sdk/src/market/MarketParams.test.ts b/packages/blue-sdk/src/market/MarketParams.test.ts index 0f738c907..c1e7fbdbd 100644 --- a/packages/blue-sdk/src/market/MarketParams.test.ts +++ b/packages/blue-sdk/src/market/MarketParams.test.ts @@ -1,3 +1,4 @@ +import { marketParamsAbi as canonicalMarketParamsAbi } from "@morpho-org/morpho-ts/abis"; import { encodeAbiParameters } from "viem"; import { describe, expect, test } from "vitest"; import { LOAN_TOKEN, marketParams } from "../__test__/fixtures.js"; @@ -9,6 +10,10 @@ import type { MarketId } from "../types.js"; import { MarketParams, marketParamsAbi } from "./MarketParams.js"; describe("MarketParams", () => { + test("re-exports the canonical market params ABI", () => { + expect(marketParamsAbi).toBe(canonicalMarketParamsAbi); + }); + test("get returns the cached params by id", () => { const params = new MarketParams(marketParams()); diff --git a/packages/blue-sdk/src/market/MarketParams.ts b/packages/blue-sdk/src/market/MarketParams.ts index 665a017c0..51f144e55 100644 --- a/packages/blue-sdk/src/market/MarketParams.ts +++ b/packages/blue-sdk/src/market/MarketParams.ts @@ -1,4 +1,5 @@ import { ZERO_ADDRESS } from "@morpho-org/morpho-ts"; +import { marketParamsAbi } from "@morpho-org/morpho-ts/abis"; import { decodeAbiParameters, type Hex } from "viem"; import { InvalidMarketParamsError, @@ -23,16 +24,7 @@ export type InputMarketParams = Pick< >; /** ABI tuple definition for Morpho Blue market params. */ -export const marketParamsAbi = { - type: "tuple", - components: [ - { type: "address", name: "loanToken" }, - { type: "address", name: "collateralToken" }, - { type: "address", name: "oracle" }, - { type: "address", name: "irm" }, - { type: "uint256", name: "lltv" }, - ], -} as const; +export { marketParamsAbi }; /** * Represents a market's configuration (also called market params). diff --git a/packages/blue-sdk/src/types.ts b/packages/blue-sdk/src/types.ts index 826449b77..69a9c30ca 100644 --- a/packages/blue-sdk/src/types.ts +++ b/packages/blue-sdk/src/types.ts @@ -5,6 +5,11 @@ import type { BigIntish as SharedBigIntish } from "@morpho-org/morpho-ts"; */ export type Address = `0x${string}`; +/** + * A 0x-prefixed hash value used for protocol identifiers. + */ +export type Hash = `0x${string}`; + /** * The id of a market used on the Blue contract */ diff --git a/packages/blue-sdk/src/vault/v2/VaultV2.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2.test.ts index 343b53ab8..544798377 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2.test.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2.test.ts @@ -347,14 +347,27 @@ describe("VaultV2MorphoMarketV1Adapter", () => { expect(adapter.type).toBe("VaultV2MorphoMarketV1Adapter"); expect(adapter.adapterId).toBe( - VaultV2MorphoMarketV1Adapter.adapterId(adapter.address), + VaultV2MorphoMarketV1Adapter.adapterCapId(adapter.address), ); expect(adapter.marketParamsList[0]).toBeInstanceOf(MarketParams); expect(adapter.ids(params)).toStrictEqual([ adapter.adapterId, - VaultV2MorphoMarketV1Adapter.collateralId(params.collateralToken), - VaultV2MorphoMarketV1Adapter.marketParamsId(adapter.address, params), + VaultV2MorphoMarketV1Adapter.collateralCapId(params.collateralToken), + VaultV2MorphoMarketV1Adapter.adapterMarketCapId(adapter.address, params), ]); + expect( + VaultV2MorphoMarketV1Adapter.marketParamsId(adapter.address, params), + ).toBe( + VaultV2MorphoMarketV1Adapter.adapterMarketCapId(adapter.address, params), + ); + expect(VaultV2MorphoMarketV1Adapter.adapterId(adapter.address)).toBe( + VaultV2MorphoMarketV1Adapter.adapterCapId(adapter.address), + ); + expect( + VaultV2MorphoMarketV1Adapter.collateralId(params.collateralToken), + ).toBe( + VaultV2MorphoMarketV1Adapter.collateralCapId(params.collateralToken), + ); }); }); @@ -418,12 +431,35 @@ describe("VaultV2MorphoMarketV1AdapterV2", () => { expect(adapter.type).toBe("VaultV2MorphoMarketV1AdapterV2"); expect(adapter.adapterId).toBe( - VaultV2MorphoMarketV1AdapterV2.adapterId(adapter.address), + VaultV2MorphoMarketV1AdapterV2.adapterCapId(adapter.address), ); expect(adapter.marketIds).toStrictEqual([m.id]); expect(adapter.adaptiveCurveIrm).toBe(ADAPTER); expect(adapter.supplyShares[m.id]).toBe(123n); - expect(adapter.ids(m.params)[0]).toBe(adapter.adapterId); + expect(adapter.ids(m.params)).toStrictEqual([ + adapter.adapterId, + VaultV2MorphoMarketV1AdapterV2.collateralCapId(m.params.collateralToken), + VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId( + adapter.address, + m.params, + ), + ]); + expect( + VaultV2MorphoMarketV1AdapterV2.marketParamsId(adapter.address, m.params), + ).toBe( + VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId( + adapter.address, + m.params, + ), + ); + expect(VaultV2MorphoMarketV1AdapterV2.adapterId(adapter.address)).toBe( + VaultV2MorphoMarketV1AdapterV2.adapterCapId(adapter.address), + ); + expect( + VaultV2MorphoMarketV1AdapterV2.collateralId(m.params.collateralToken), + ).toBe( + VaultV2MorphoMarketV1AdapterV2.collateralCapId(m.params.collateralToken), + ); }); }); @@ -530,10 +566,13 @@ describe("VaultV2MorphoVaultV1Adapter", () => { expect(adapter.type).toBe("VaultV2MorphoVaultV1Adapter"); expect(adapter.adapterId).toBe( - VaultV2MorphoVaultV1Adapter.adapterId(adapter.address), + VaultV2MorphoVaultV1Adapter.adapterCapId(adapter.address), ); expect(adapter.morphoVaultV1).toBe(RECIPIENT); expect(adapter.ids()).toStrictEqual([adapter.adapterId]); + expect(VaultV2MorphoVaultV1Adapter.adapterId(adapter.address)).toBe( + VaultV2MorphoVaultV1Adapter.adapterCapId(adapter.address), + ); }); }); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2.ts b/packages/blue-sdk/src/vault/v2/VaultV2.ts index da75e3c9f..41df24465 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2.ts @@ -1,10 +1,11 @@ -import { type Address, type Hash, type Hex, zeroAddress } from "viem"; +import { type Address, type Hex, zeroAddress } from "viem"; import { VaultV2Errors } from "../../errors.js"; import { MathLib, type RoundingDirection } from "../../math/index.js"; import { type IToken, WrappedToken } from "../../token/index.js"; -import type { BigIntish } from "../../types.js"; +import type { BigIntish, Hash } from "../../types.js"; import { type CapacityLimit, CapacityLimitReason } from "../../utils.js"; import type { IAccrualVaultV2Adapter } from "./VaultV2Adapter.js"; +import { VaultV2Utils } from "./VaultV2Utils.js"; /** Plain input shape for one Vault V2 liquidity allocation. */ export interface IVaultV2Allocation { @@ -189,28 +190,13 @@ export class AccrualVaultV2 extends VaultV2 implements IAccrualVaultV2 { // At this stage: `liquidityAdapterLimit.value <= assets` - for (const { absoluteCap, relativeCap, allocation } of this - .liquidityAllocations) { - // `absoluteCap` can be set lower than `allocation`. - const absoluteMaxDeposit = MathLib.zeroFloorSub(absoluteCap, allocation); - if (liquidityAdapterLimit.value > absoluteMaxDeposit) - liquidityAdapterLimit = { - value: absoluteMaxDeposit, - limiter: CapacityLimitReason.vaultV2_absoluteCap, - }; - - if (relativeCap !== MathLib.WAD) { - // `relativeCap` can be set lower than `allocation / _totalAssets`. - const relativeMaxDeposit = MathLib.zeroFloorSub( - MathLib.wMulDown(this._totalAssets, relativeCap), - allocation, - ); - if (liquidityAdapterLimit.value > relativeMaxDeposit) - liquidityAdapterLimit = { - value: relativeMaxDeposit, - limiter: CapacityLimitReason.vaultV2_relativeCap, - }; - } + for (const allocation of this.liquidityAllocations) { + const allocationLimit = VaultV2Utils.allocationHeadroom( + allocation, + this._totalAssets, + ); + if (liquidityAdapterLimit.value > allocationLimit.value) + liquidityAdapterLimit = allocationLimit; } return liquidityAdapterLimit; diff --git a/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts b/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts index e701bb5e8..20cc7daeb 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts @@ -1,5 +1,5 @@ -import type { Address, Hash, Hex } from "viem"; -import type { BigIntish } from "../../types.js"; +import type { Address, Hex } from "viem"; +import type { BigIntish, Hash } from "../../types.js"; import type { CapacityLimit } from "../../utils.js"; /** Plain input shape for a Morpho Vault V2 adapter. */ diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfig.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfig.test.ts new file mode 100644 index 000000000..651f42de4 --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfig.test.ts @@ -0,0 +1,17 @@ +import { zeroAddress } from "viem"; +import { describe, expect, test } from "vitest"; +import { VaultV2BlueMarketPublicAllocatorConfig } from "./VaultV2BlueMarketPublicAllocatorConfig.js"; + +describe("VaultV2BlueMarketPublicAllocatorConfig", () => { + test("default", () => { + const config = new VaultV2BlueMarketPublicAllocatorConfig({ + vault: zeroAddress, + adapter: zeroAddress, + adapterMarketCapId: `0x${"00".repeat(32)}`, + absoluteCap: 100n, + canPullFromMarket: true, + }); + + expect(config.getMaxIn(40n)).toBe(60n); + }); +}); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfig.ts b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfig.ts new file mode 100644 index 000000000..fd0e43bb1 --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfig.ts @@ -0,0 +1,93 @@ +import type { Address, BigIntish, Hash } from "../../types.js"; +import { VaultV2BlueMarketPublicAllocatorConfigUtils } from "./VaultV2BlueMarketPublicAllocatorConfigUtils.js"; + +/** Plain input shape for one Vault V2 adapter-market's BluePublicAllocator configuration. */ +export interface IVaultV2BlueMarketPublicAllocatorConfig { + /** Configured Vault V2 address. */ + readonly vault: Address; + /** Vault V2 MorphoMarketV1AdapterV2 address. */ + readonly adapter: Address; + /** Adapter-scoped market-parameters id used by the allocator mappings. */ + readonly adapterMarketCapId: Hash; + /** Maximum post-state allocation accepted by the allocator. */ + readonly absoluteCap: bigint; + /** Whether the allocator may pull assets from this adapter-market pair. */ + readonly canPullFromMarket: boolean; +} + +/** + * Represents BluePublicAllocator state for one Vault V2 adapter-market pair. + * + * @example + * ```ts + * import { VaultV2BlueMarketPublicAllocatorConfig } from "@morpho-org/blue-sdk"; + * + * const config = new VaultV2BlueMarketPublicAllocatorConfig({ + * vault: "0x0000000000000000000000000000000000000001", + * adapter: "0x0000000000000000000000000000000000000002", + * adapterMarketCapId: "0x0000000000000000000000000000000000000000000000000000000000000003", + * absoluteCap: 100n, + * canPullFromMarket: true, + * }); + * ``` + */ +export class VaultV2BlueMarketPublicAllocatorConfig + implements IVaultV2BlueMarketPublicAllocatorConfig +{ + /** Configured Vault V2 address. */ + public readonly vault: Address; + /** Vault V2 MorphoMarketV1AdapterV2 address. */ + public readonly adapter: Address; + /** Adapter-scoped market-parameters id used by the allocator mappings. */ + public readonly adapterMarketCapId: Hash; + /** Maximum post-state allocation accepted by the allocator. */ + public readonly absoluteCap: bigint; + /** Whether the allocator may pull assets from this adapter-market pair. */ + public readonly canPullFromMarket: boolean; + + /** + * Creates an adapter-market BluePublicAllocator configuration. + * + * @param config - Plain adapter-market allocator configuration. + */ + public constructor({ + vault, + adapter, + adapterMarketCapId, + absoluteCap, + canPullFromMarket, + }: IVaultV2BlueMarketPublicAllocatorConfig) { + this.vault = vault; + this.adapter = adapter; + this.adapterMarketCapId = adapterMarketCapId; + this.absoluteCap = absoluteCap; + this.canPullFromMarket = canPullFromMarket; + } + + /** + * Computes the assets that may still be allocated under the allocator cap. + * + * @param allocation - Effective current allocation, including untracked assets. + * @returns Remaining allocator capacity, floored at zero. + * @example + * ```ts + * import { VaultV2BlueMarketPublicAllocatorConfig } from "@morpho-org/blue-sdk"; + * + * const config = new VaultV2BlueMarketPublicAllocatorConfig({ + * vault: "0x0000000000000000000000000000000000000001", + * adapter: "0x0000000000000000000000000000000000000002", + * adapterMarketCapId: "0x0000000000000000000000000000000000000000000000000000000000000003", + * absoluteCap: 100n, + * canPullFromMarket: true, + * }); + * const maxIn = config.getMaxIn(40n); + * // maxIn === 60n + * ``` + */ + public getMaxIn(allocation: BigIntish) { + return VaultV2BlueMarketPublicAllocatorConfigUtils.getMaxIn( + this, + allocation, + ); + } +} diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfigUtils.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfigUtils.test.ts new file mode 100644 index 000000000..a08272582 --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfigUtils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { VaultV2BlueMarketPublicAllocatorConfigUtils } from "./VaultV2BlueMarketPublicAllocatorConfigUtils.js"; + +describe("VaultV2BlueMarketPublicAllocatorConfigUtils.getMaxIn", () => { + test("behavior: floors exhausted capacity at zero", () => { + expect( + VaultV2BlueMarketPublicAllocatorConfigUtils.getMaxIn( + { absoluteCap: 100n }, + 101n, + ), + ).toBe(0n); + }); +}); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfigUtils.ts b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfigUtils.ts new file mode 100644 index 000000000..cd211c923 --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BlueMarketPublicAllocatorConfigUtils.ts @@ -0,0 +1,30 @@ +import { MathLib } from "../../math/index.js"; +import type { BigIntish } from "../../types.js"; +import type { IVaultV2BlueMarketPublicAllocatorConfig } from "./VaultV2BlueMarketPublicAllocatorConfig.js"; + +/** Deterministic helpers for Vault V2 adapter-market BluePublicAllocator configuration. */ +export namespace VaultV2BlueMarketPublicAllocatorConfigUtils { + /** + * Computes the assets that may still be allocated under the allocator cap. + * + * @param config - Configuration or compatible object carrying the absolute cap. + * @param allocation - Effective current allocation, including untracked assets. + * @returns Remaining allocator capacity, floored at zero. + * @example + * ```ts + * import { VaultV2BlueMarketPublicAllocatorConfigUtils } from "@morpho-org/blue-sdk"; + * + * const maxIn = VaultV2BlueMarketPublicAllocatorConfigUtils.getMaxIn( + * { absoluteCap: 100n }, + * 40n, + * ); + * // maxIn === 60n + * ``` + */ + export function getMaxIn( + config: Pick, + allocation: BigIntish, + ) { + return MathLib.zeroFloorSub(config.absoluteCap, allocation); + } +} diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfig.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfig.test.ts new file mode 100644 index 000000000..0591ee88d --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfig.test.ts @@ -0,0 +1,15 @@ +import { zeroAddress } from "viem"; +import { describe, expect, test } from "vitest"; +import { VaultV2BluePublicAllocatorConfig } from "./VaultV2BluePublicAllocatorConfig.js"; + +describe("VaultV2BluePublicAllocatorConfig", () => { + test("default", () => { + const config = new VaultV2BluePublicAllocatorConfig({ + vault: zeroAddress, + canPullFromIdle: true, + penalty: 500_000_000_000_000_000n, + }); + + expect(config.getPenaltyAssets(3n)).toBe(2n); + }); +}); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfig.ts b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfig.ts new file mode 100644 index 000000000..4cfed9945 --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfig.ts @@ -0,0 +1,74 @@ +import type { Address, BigIntish } from "../../types.js"; +import { VaultV2BluePublicAllocatorConfigUtils } from "./VaultV2BluePublicAllocatorConfigUtils.js"; + +/** Plain input shape for one Vault V2's BluePublicAllocator configuration. */ +export interface IVaultV2BluePublicAllocatorConfig { + /** Configured Vault V2 address. */ + readonly vault: Address; + /** Whether the allocator may pull the vault's idle assets into a Blue market. */ + readonly canPullFromIdle: boolean; + /** Proportional vault-asset penalty charged for each call, scaled by WAD. */ + readonly penalty: bigint; +} + +/** + * Represents one Vault V2's BluePublicAllocator configuration. + * + * @example + * ```ts + * import { VaultV2BluePublicAllocatorConfig } from "@morpho-org/blue-sdk"; + * + * const config = new VaultV2BluePublicAllocatorConfig({ + * vault: "0x0000000000000000000000000000000000000001", + * canPullFromIdle: true, + * penalty: 500_000_000_000_000_000n, + * }); + * ``` + */ +export class VaultV2BluePublicAllocatorConfig + implements IVaultV2BluePublicAllocatorConfig +{ + /** Configured Vault V2 address. */ + public readonly vault: Address; + /** Whether the allocator may pull the vault's idle assets into a Blue market. */ + public readonly canPullFromIdle: boolean; + /** Proportional vault-asset penalty charged for each call, scaled by WAD. */ + public readonly penalty: bigint; + + /** + * Creates a Vault V2 BluePublicAllocator configuration. + * + * @param config - Plain allocator configuration. + */ + public constructor({ + vault, + canPullFromIdle, + penalty, + }: IVaultV2BluePublicAllocatorConfig) { + this.vault = vault; + this.canPullFromIdle = canPullFromIdle; + this.penalty = penalty; + } + + /** + * Computes the independently rounded penalty charged for one reallocation. + * + * @param assets - Assets reallocated by the allocator. + * @returns Penalty assets rounded up exactly as the allocator charges them. + * @example + * ```ts + * import { VaultV2BluePublicAllocatorConfig } from "@morpho-org/blue-sdk"; + * + * const config = new VaultV2BluePublicAllocatorConfig({ + * vault: "0x0000000000000000000000000000000000000001", + * canPullFromIdle: true, + * penalty: 500_000_000_000_000_000n, + * }); + * const penaltyAssets = config.getPenaltyAssets(3n); + * // penaltyAssets === 2n + * ``` + */ + public getPenaltyAssets(assets: BigIntish) { + return VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets(this, assets); + } +} diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfigUtils.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfigUtils.test.ts new file mode 100644 index 000000000..c99c9b90a --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfigUtils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { VaultV2BluePublicAllocatorConfigUtils } from "./VaultV2BluePublicAllocatorConfigUtils.js"; + +describe("VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets", () => { + test("default", () => { + expect( + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + { penalty: 500_000_000_000_000_000n }, + 3n, + ), + ).toBe(2n); + }); +}); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfigUtils.ts b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfigUtils.ts new file mode 100644 index 000000000..e52b1e39d --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2BluePublicAllocatorConfigUtils.ts @@ -0,0 +1,30 @@ +import { MathLib } from "../../math/index.js"; +import type { BigIntish } from "../../types.js"; +import type { IVaultV2BluePublicAllocatorConfig } from "./VaultV2BluePublicAllocatorConfig.js"; + +/** Deterministic helpers for Vault V2 BluePublicAllocator configuration. */ +export namespace VaultV2BluePublicAllocatorConfigUtils { + /** + * Computes the independently rounded penalty charged for one reallocation. + * + * @param config - Configuration or compatible object carrying the WAD-scaled penalty. + * @param assets - Assets reallocated by the allocator. + * @returns Penalty assets rounded up exactly as the allocator charges them. + * @example + * ```ts + * import { VaultV2BluePublicAllocatorConfigUtils } from "@morpho-org/blue-sdk"; + * + * const penaltyAssets = VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + * { penalty: 500_000_000_000_000_000n }, + * 3n, + * ); + * // penaltyAssets === 2n + * ``` + */ + export function getPenaltyAssets( + config: Pick, + assets: BigIntish, + ) { + return MathLib.wMulUp(assets, config.penalty); + } +} diff --git a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts index 93d5cc712..109344163 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts @@ -5,7 +5,7 @@ import { marketParamsAbi, } from "../../market/index.js"; import type { AccrualPosition } from "../../position/index.js"; -import type { BigIntish } from "../../types.js"; +import type { BigIntish, Hash } from "../../types.js"; import { CapacityLimitReason } from "../../utils.js"; import type { IAccrualVaultV2Adapter, @@ -27,7 +27,17 @@ export class VaultV2MorphoMarketV1Adapter { public declare readonly type: "VaultV2MorphoMarketV1Adapter"; - static adapterId(address: Address) { + /** + * Returns the adapter-wide allocation-cap id. + * + * @param address - Adapter address. + * @returns The adapter-wide allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoMarketV1Adapter.adapterCapId(adapter); + * ``` + */ + static adapterCapId(address: Address) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }], @@ -36,7 +46,28 @@ export class VaultV2MorphoMarketV1Adapter ); } - static collateralId(address: Address) { + /** + * Returns the adapter-wide allocation-cap id. + * + * @param address - Adapter address. + * @returns The adapter-wide allocation-cap id. + * @deprecated Use {@link VaultV2MorphoMarketV1Adapter.adapterCapId}. + */ + static adapterId(address: Address) { + return VaultV2MorphoMarketV1Adapter.adapterCapId(address); + } + + /** + * Returns the collateral-wide allocation-cap id. + * + * @param address - Collateral token address. + * @returns The collateral-wide allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoMarketV1Adapter.collateralCapId(collateral); + * ``` + */ + static collateralCapId(address: Address) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }], @@ -45,7 +76,32 @@ export class VaultV2MorphoMarketV1Adapter ); } - static marketParamsId(address: Address, params: MarketParams) { + /** + * Returns the collateral-wide allocation-cap id. + * + * @param address - Collateral token address. + * @returns The collateral-wide allocation-cap id. + * @deprecated Use {@link VaultV2MorphoMarketV1Adapter.collateralCapId}. + */ + static collateralId(address: Address) { + return VaultV2MorphoMarketV1Adapter.collateralCapId(address); + } + + /** + * Returns the adapter-market allocation-cap id. + * + * @param address - Adapter address. + * @param params - Morpho Blue market parameters. + * @returns The adapter-market allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoMarketV1Adapter.adapterMarketCapId( + * adapter, + * marketParams, + * ); + * ``` + */ + static adapterMarketCapId(address: Address, params: MarketParams) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }, marketParamsAbi], @@ -54,6 +110,18 @@ export class VaultV2MorphoMarketV1Adapter ); } + /** + * Returns the adapter-market allocation-cap id. + * + * @param address - Adapter address. + * @param params - Morpho Blue market parameters. + * @returns The adapter-market allocation-cap id. + * @deprecated Use {@link VaultV2MorphoMarketV1Adapter.adapterMarketCapId}. + */ + static marketParamsId(address: Address, params: MarketParams) { + return VaultV2MorphoMarketV1Adapter.adapterMarketCapId(address, params); + } + public marketParamsList: MarketParams[]; constructor({ @@ -63,7 +131,9 @@ export class VaultV2MorphoMarketV1Adapter super({ ...vaultV2Adapter, type: "VaultV2MorphoMarketV1Adapter", - adapterId: VaultV2MorphoMarketV1Adapter.adapterId(vaultV2Adapter.address), + adapterId: VaultV2MorphoMarketV1Adapter.adapterCapId( + vaultV2Adapter.address, + ), }); this.marketParamsList = marketParamsList.map( @@ -71,11 +141,42 @@ export class VaultV2MorphoMarketV1Adapter ); } - public ids(params: MarketParams) { + /** + * Returns this adapter's allocation-cap ids for a Morpho Blue market. + * + * @param params - Morpho Blue market parameters. + * @returns A readonly tuple containing the adapter, collateral, and adapter-market + * allocation-cap ids, in that order. + * @example + * ```ts + * import { + * MarketParams, + * VaultV2MorphoMarketV1Adapter, + * } from "@morpho-org/blue-sdk"; + * import { ZERO_ADDRESS } from "@morpho-org/morpho-ts"; + * + * const marketParams = MarketParams.idle(ZERO_ADDRESS); + * const adapter = new VaultV2MorphoMarketV1Adapter({ + * address: ZERO_ADDRESS, + * parentVault: ZERO_ADDRESS, + * skimRecipient: ZERO_ADDRESS, + * marketParamsList: [marketParams], + * }); + * const [adapterCapId, collateralCapId, adapterMarketCapId] = + * adapter.ids(marketParams); + * ``` + */ + public ids( + params: MarketParams, + ): readonly [ + adapterCapId: Hash, + collateralCapId: Hash, + adapterMarketCapId: Hash, + ] { return [ this.adapterId, - VaultV2MorphoMarketV1Adapter.collateralId(params.collateralToken), - VaultV2MorphoMarketV1Adapter.marketParamsId(this.address, params), + VaultV2MorphoMarketV1Adapter.collateralCapId(params.collateralToken), + VaultV2MorphoMarketV1Adapter.adapterMarketCapId(this.address, params), ]; } } diff --git a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts index fa243d5b1..a02d3145f 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts @@ -4,7 +4,7 @@ import { MarketParams, marketParamsAbi, } from "../../market/index.js"; -import type { BigIntish, MarketId } from "../../types.js"; +import type { BigIntish, Hash, MarketId } from "../../types.js"; import { CapacityLimitReason } from "../../utils.js"; import type { IAccrualVaultV2Adapter, @@ -28,7 +28,17 @@ export class VaultV2MorphoMarketV1AdapterV2 { public declare readonly type: "VaultV2MorphoMarketV1AdapterV2"; - static adapterId(address: Address) { + /** + * Returns the adapter-wide allocation-cap id. + * + * @param address - Adapter address. + * @returns The adapter-wide allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoMarketV1AdapterV2.adapterCapId(adapter); + * ``` + */ + static adapterCapId(address: Address) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }], @@ -37,7 +47,28 @@ export class VaultV2MorphoMarketV1AdapterV2 ); } - static collateralId(address: Address) { + /** + * Returns the adapter-wide allocation-cap id. + * + * @param address - Adapter address. + * @returns The adapter-wide allocation-cap id. + * @deprecated Use {@link VaultV2MorphoMarketV1AdapterV2.adapterCapId}. + */ + static adapterId(address: Address) { + return VaultV2MorphoMarketV1AdapterV2.adapterCapId(address); + } + + /** + * Returns the collateral-wide allocation-cap id. + * + * @param address - Collateral token address. + * @returns The collateral-wide allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoMarketV1AdapterV2.collateralCapId(collateral); + * ``` + */ + static collateralCapId(address: Address) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }], @@ -46,7 +77,32 @@ export class VaultV2MorphoMarketV1AdapterV2 ); } - static marketParamsId(address: Address, params: MarketParams) { + /** + * Returns the collateral-wide allocation-cap id. + * + * @param address - Collateral token address. + * @returns The collateral-wide allocation-cap id. + * @deprecated Use {@link VaultV2MorphoMarketV1AdapterV2.collateralCapId}. + */ + static collateralId(address: Address) { + return VaultV2MorphoMarketV1AdapterV2.collateralCapId(address); + } + + /** + * Returns the adapter-market allocation-cap id. + * + * @param address - Adapter address. + * @param params - Morpho Blue market parameters. + * @returns The adapter-market allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId( + * adapter, + * marketParams, + * ); + * ``` + */ + static adapterMarketCapId(address: Address, params: MarketParams) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }, marketParamsAbi], @@ -55,6 +111,18 @@ export class VaultV2MorphoMarketV1AdapterV2 ); } + /** + * Returns the adapter-market allocation-cap id. + * + * @param address - Adapter address. + * @param params - Morpho Blue market parameters. + * @returns The adapter-market allocation-cap id. + * @deprecated Use {@link VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId}. + */ + static marketParamsId(address: Address, params: MarketParams) { + return VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId(address, params); + } + public marketIds: MarketId[]; public adaptiveCurveIrm: Address; public supplyShares: Record; @@ -68,7 +136,7 @@ export class VaultV2MorphoMarketV1AdapterV2 super({ ...vaultV2Adapter, type: "VaultV2MorphoMarketV1AdapterV2", - adapterId: VaultV2MorphoMarketV1AdapterV2.adapterId( + adapterId: VaultV2MorphoMarketV1AdapterV2.adapterCapId( vaultV2Adapter.address, ), }); @@ -78,11 +146,44 @@ export class VaultV2MorphoMarketV1AdapterV2 this.supplyShares = supplyShares; } - public ids(params: MarketParams) { + /** + * Returns this adapter's allocation-cap ids for a Morpho Blue market. + * + * @param params - Morpho Blue market parameters. + * @returns A readonly tuple containing the adapter, collateral, and adapter-market + * allocation-cap ids, in that order. + * @example + * ```ts + * import { + * MarketParams, + * VaultV2MorphoMarketV1AdapterV2, + * } from "@morpho-org/blue-sdk"; + * import { ZERO_ADDRESS } from "@morpho-org/morpho-ts"; + * + * const marketParams = MarketParams.idle(ZERO_ADDRESS); + * const adapter = new VaultV2MorphoMarketV1AdapterV2({ + * address: ZERO_ADDRESS, + * parentVault: ZERO_ADDRESS, + * skimRecipient: ZERO_ADDRESS, + * marketIds: [], + * adaptiveCurveIrm: ZERO_ADDRESS, + * supplyShares: {}, + * }); + * const [adapterCapId, collateralCapId, adapterMarketCapId] = + * adapter.ids(marketParams); + * ``` + */ + public ids( + params: MarketParams, + ): readonly [ + adapterCapId: Hash, + collateralCapId: Hash, + adapterMarketCapId: Hash, + ] { return [ this.adapterId, - VaultV2MorphoMarketV1AdapterV2.collateralId(params.collateralToken), - VaultV2MorphoMarketV1AdapterV2.marketParamsId(this.address, params), + VaultV2MorphoMarketV1AdapterV2.collateralCapId(params.collateralToken), + VaultV2MorphoMarketV1AdapterV2.adapterMarketCapId(this.address, params), ]; } } diff --git a/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts b/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts index 8510a0a39..030cc2d9f 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts @@ -9,7 +9,7 @@ export interface IVaultV2MorphoVaultV1Adapter morphoVaultV1: Address; } -import type { BigIntish } from "../../types.js"; +import type { BigIntish, Hash } from "../../types.js"; import type { AccrualVault } from "../Vault.js"; import type { IAccrualVaultV2Adapter, @@ -23,7 +23,17 @@ export class VaultV2MorphoVaultV1Adapter { public declare readonly type: "VaultV2MorphoVaultV1Adapter"; - static adapterId(address: Address) { + /** + * Returns the adapter-wide allocation-cap id. + * + * @param address - Adapter address. + * @returns The adapter-wide allocation-cap id. + * @example + * ```ts + * const id = VaultV2MorphoVaultV1Adapter.adapterCapId(adapter); + * ``` + */ + static adapterCapId(address: Address) { return keccak256( encodeAbiParameters( [{ type: "string" }, { type: "address" }], @@ -32,6 +42,17 @@ export class VaultV2MorphoVaultV1Adapter ); } + /** + * Returns the adapter-wide allocation-cap id. + * + * @param address - Adapter address. + * @returns The adapter-wide allocation-cap id. + * @deprecated Use {@link VaultV2MorphoVaultV1Adapter.adapterCapId}. + */ + static adapterId(address: Address) { + return VaultV2MorphoVaultV1Adapter.adapterCapId(address); + } + public readonly morphoVaultV1: Address; constructor({ @@ -41,13 +62,33 @@ export class VaultV2MorphoVaultV1Adapter super({ ...vaultV2Adapter, type: "VaultV2MorphoVaultV1Adapter", - adapterId: VaultV2MorphoVaultV1Adapter.adapterId(vaultV2Adapter.address), + adapterId: VaultV2MorphoVaultV1Adapter.adapterCapId( + vaultV2Adapter.address, + ), }); this.morphoVaultV1 = morphoVaultV1; } - public ids() { + /** + * Returns this adapter's allocation-cap ids. + * + * @returns A readonly tuple containing the adapter-wide allocation-cap id. + * @example + * ```ts + * import { VaultV2MorphoVaultV1Adapter } from "@morpho-org/blue-sdk"; + * import { ZERO_ADDRESS } from "@morpho-org/morpho-ts"; + * + * const adapter = new VaultV2MorphoVaultV1Adapter({ + * address: ZERO_ADDRESS, + * parentVault: ZERO_ADDRESS, + * skimRecipient: ZERO_ADDRESS, + * morphoVaultV1: ZERO_ADDRESS, + * }); + * const [adapterCapId] = adapter.ids(); + * ``` + */ + public ids(): readonly [adapterCapId: Hash] { return [this.adapterId]; } } diff --git a/packages/blue-sdk/src/vault/v2/VaultV2Utils.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2Utils.test.ts new file mode 100644 index 000000000..ac55b48ce --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2Utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; +import { MathLib } from "../../math/index.js"; +import { CapacityLimitReason } from "../../utils.js"; +import { VaultV2Utils } from "./VaultV2Utils.js"; + +const allocation = { + id: `0x${"01".repeat(32)}` as const, + absoluteCap: 1_000n, + relativeCap: MathLib.WAD, + allocation: 400n, +}; + +describe("VaultV2Utils.allocationHeadroom", () => { + test("default: returns absolute-cap headroom", () => { + expect(VaultV2Utils.allocationHeadroom(allocation, 1_000n)).toStrictEqual({ + value: 600n, + limiter: CapacityLimitReason.vaultV2_absoluteCap, + }); + }); + + test("behavior: returns relative-cap headroom when it binds", () => { + expect( + VaultV2Utils.allocationHeadroom( + { ...allocation, relativeCap: MathLib.WAD / 2n }, + 1_000n, + ), + ).toStrictEqual({ + value: 100n, + limiter: CapacityLimitReason.vaultV2_relativeCap, + }); + }); + + test("behavior: floors caps below the live allocation at zero", () => { + expect( + VaultV2Utils.allocationHeadroom( + { + ...allocation, + absoluteCap: 300n, + relativeCap: MathLib.WAD / 4n, + }, + 1_000n, + ), + ).toStrictEqual({ + value: 0n, + limiter: CapacityLimitReason.vaultV2_absoluteCap, + }); + }); + + test("behavior: WAD relative caps do not constrain absolute headroom", () => { + expect( + VaultV2Utils.allocationHeadroom( + { ...allocation, absoluteCap: 2_000n, relativeCap: MathLib.WAD }, + 500n, + ), + ).toStrictEqual({ + value: 1_600n, + limiter: CapacityLimitReason.vaultV2_absoluteCap, + }); + }); +}); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2Utils.ts b/packages/blue-sdk/src/vault/v2/VaultV2Utils.ts new file mode 100644 index 000000000..7b4423668 --- /dev/null +++ b/packages/blue-sdk/src/vault/v2/VaultV2Utils.ts @@ -0,0 +1,54 @@ +import { MathLib } from "../../math/index.js"; +import type { BigIntish } from "../../types.js"; +import { type CapacityLimit, CapacityLimitReason } from "../../utils.js"; +import type { IVaultV2Allocation } from "./VaultV2.js"; + +/** Deterministic helpers for Vault V2 allocation accounting. */ +export namespace VaultV2Utils { + /** + * Computes the remaining assets permitted by one Vault V2 allocation's + * absolute and relative caps. + * + * @param allocation - Current allocation and its absolute and relative caps. + * @param firstTotalAssets - Transaction-frozen Vault V2 total assets used as the relative-cap denominator. + * @returns The remaining allocation capacity and the cap that binds it. + * @example + * ```ts + * import { VaultV2Utils } from "@morpho-org/blue-sdk"; + * + * const headroom = VaultV2Utils.allocationHeadroom( + * { id: "0x0000000000000000000000000000000000000000000000000000000000000000", absoluteCap: 100n, relativeCap: 500000000000000000n, allocation: 40n }, + * 160n, + * ); + * // headroom.value === 40n + * ``` + */ + export function allocationHeadroom( + allocation: Readonly, + firstTotalAssets: BigIntish, + ): CapacityLimit { + const absoluteHeadroom = MathLib.zeroFloorSub( + allocation.absoluteCap, + allocation.allocation, + ); + let limit: CapacityLimit = { + value: absoluteHeadroom, + limiter: CapacityLimitReason.vaultV2_absoluteCap, + }; + + if (allocation.relativeCap !== MathLib.WAD) { + const relativeHeadroom = MathLib.zeroFloorSub( + MathLib.wMulDown(BigInt(firstTotalAssets), allocation.relativeCap), + allocation.allocation, + ); + if (relativeHeadroom < limit.value) { + limit = { + value: relativeHeadroom, + limiter: CapacityLimitReason.vaultV2_relativeCap, + }; + } + } + + return limit; + } +} diff --git a/packages/blue-sdk/src/vault/v2/index.ts b/packages/blue-sdk/src/vault/v2/index.ts index dc5d27071..e033a726f 100644 --- a/packages/blue-sdk/src/vault/v2/index.ts +++ b/packages/blue-sdk/src/vault/v2/index.ts @@ -1,5 +1,10 @@ export * from "./VaultV2.js"; export * from "./VaultV2Adapter.js"; +export * from "./VaultV2BlueMarketPublicAllocatorConfig.js"; +export * from "./VaultV2BlueMarketPublicAllocatorConfigUtils.js"; +export * from "./VaultV2BluePublicAllocatorConfig.js"; +export * from "./VaultV2BluePublicAllocatorConfigUtils.js"; export * from "./VaultV2MorphoMarketV1Adapter.js"; export * from "./VaultV2MorphoMarketV1AdapterV2.js"; export * from "./VaultV2MorphoVaultV1Adapter.js"; +export * from "./VaultV2Utils.js"; diff --git a/packages/liquidity-sdk-viem/src/loader.test.ts b/packages/liquidity-sdk-viem/src/loader.test.ts index 32ed1f845..2c8ec1a49 100644 --- a/packages/liquidity-sdk-viem/src/loader.test.ts +++ b/packages/liquidity-sdk-viem/src/loader.test.ts @@ -9,7 +9,7 @@ import { blueAbi, metaMorphoAbi, metaMorphoFactoryAbi, - publicAllocatorAbi, + vaultV1PublicAllocatorAbi, } from "@morpho-org/blue-sdk-viem"; import { BLUE_API_GRAPHQL_URL } from "@morpho-org/morpho-ts"; import { createMockClient, type MockClientHandle } from "@morpho-org/test/mock"; @@ -29,11 +29,11 @@ import { mainnet } from "viem/chains"; import { afterEach, describe, expect, test } from "vitest"; import { LiquidityLoader } from "./loader.js"; -const { morpho, publicAllocator, metaMorphoFactory } = getChainAddresses( +const { morpho, vaultV1PublicAllocator, metaMorphoFactory } = getChainAddresses( ChainId.EthMainnet, ); -if (publicAllocator == null || metaMorphoFactory == null) { +if (vaultV1PublicAllocator == null || metaMorphoFactory == null) { throw new Error( "Ethereum mainnet addresses must include allocator contracts", ); @@ -340,7 +340,7 @@ const setupLoaderMockClient = ({ address: vault, abi: metaMorphoAbi, functionName: "isAllocator", - args: [publicAllocator], + args: [vaultV1PublicAllocator], result: true, }); addRead({ @@ -380,22 +380,22 @@ const setupLoaderMockClient = ({ }); addRead({ - address: publicAllocator, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "admin", args: [vault], result: owner, }); addRead({ - address: publicAllocator, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "fee", args: [vault], result: 0n, }); addRead({ - address: publicAllocator, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "accruedFee", args: [vault], result: 0n, @@ -416,8 +416,8 @@ const setupLoaderMockClient = ({ result: [targetPendingCapValue, targetPendingCapValidAt], }); addRead({ - address: publicAllocator, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "flowCaps", args: [vault, targetMarketId], result: [10_000n, 0n], @@ -437,8 +437,8 @@ const setupLoaderMockClient = ({ result: [10_000n, 0n], }); addRead({ - address: publicAllocator, - abi: publicAllocatorAbi, + address: vaultV1PublicAllocator, + abi: vaultV1PublicAllocatorAbi, functionName: "flowCaps", args: [vault, sourceMarketId], result: [0n, 10_000n], diff --git a/packages/liquidity-sdk-viem/src/loader.ts b/packages/liquidity-sdk-viem/src/loader.ts index b824cafbf..edb40f95b 100644 --- a/packages/liquidity-sdk-viem/src/loader.ts +++ b/packages/liquidity-sdk-viem/src/loader.ts @@ -16,24 +16,21 @@ import { apiSdk } from "./api/index.js"; const REALLOCATION_SIMULATION_DELAY = 3_600n; /** * Optional tuning for the shared-liquidity source-market withdrawal ceiling. - * - * @deprecated The source-market withdrawal ceiling is fixed at 90% - * (`DEFAULT_WITHDRAWAL_TARGET_UTILIZATION` in `@morpho-org/morpho-sdk`) and will - * stop being configurable in the next major. Overrides are still honored for now. */ export interface LiquidityParameters { /** * The default maximum utilization allowed to reach to find shared liquidity (scaled by WAD). * - * @deprecated Fixed at 90% and will be removed in the next major. + * @default 90% (900000000000000000n) */ defaultMaxWithdrawalUtilization?: bigint; /** * If provided, defines the maximum utilization allowed to reach for each market, defaulting to `defaultMaxWithdrawalUtilization`. * - * @deprecated Fixed at 90% and will be removed in the next major. The Morpho - * API's `targetWithdrawUtilization` is no longer consulted. + * @deprecated Per-market source ceilings will be removed in the next major. + * Use `defaultMaxWithdrawalUtilization` to configure one ceiling for every + * source. The Morpho API's `targetWithdrawUtilization` is no longer consulted. */ maxWithdrawalUtilization?: Record; } @@ -51,10 +48,7 @@ export class LiquidityLoader { constructor( public client: Client, - /** - * @deprecated The source-market withdrawal ceiling is fixed at 90% and will - * stop being configurable in the next major. Overrides are still honored for now. - */ + /** Shared-liquidity source-market withdrawal tuning. */ public readonly parameters: LiquidityParameters = {}, ) { this.dataLoader = new DataLoader( @@ -190,8 +184,8 @@ export class LiquidityLoader { // The source-market withdrawal ceiling defaults to 90% // (DEFAULT_WITHDRAWAL_TARGET_UTILIZATION) inside // `getMarketPublicReallocations`; the API's per-market - // `targetWithdrawUtilization` is no longer consulted. Deprecated - // `parameters` overrides are still forwarded for backward compatibility. + // `targetWithdrawUtilization` is no longer consulted. + // Caller `parameters` overrides are forwarded to the planner. const { data: endState, withdrawals } = startState.getMarketPublicReallocations(uniqueKey, { ...parameters, diff --git a/packages/morpho-sdk/AGENTS.md b/packages/morpho-sdk/AGENTS.md index c90ca65c8..56d047291 100644 --- a/packages/morpho-sdk/AGENTS.md +++ b/packages/morpho-sdk/AGENTS.md @@ -26,8 +26,10 @@ Protocol terms used across this package's docs and JSDoc: - **VaultV2** — successor vault with adapter-based liquidity routing and `forceDeallocate`. - **bundler3** — the bundler entry point; receives a sequence of adapter actions in one transaction. - **GeneralAdapter1** — the bundler-side adapter that holds approvals/auth and executes Morpho calls on the user's behalf. Required as the spender for ERC-20 approvals on every bundled path; required as authorized operator on Morpho for `borrow`, `supplyCollateralBorrow`, `repayWithdrawCollateral`, and `withdraw` (the supplier-side path). -- **PublicAllocator** — Morpho contract that lets vault curators move liquidity between markets within a vault (`reallocateTo(...)`). +- **PublicAllocator V1** — MetaMorpho allocator that moves liquidity from one or more sorted source markets into a target via `reallocateTo(...)`; each call pays one `fee`. +- **BluePublicAllocator** — the single canonical Vault V2 allocator registered per chain, which moves one source market or vault idle liquidity into the enclosing Blue action's target market via `reallocate(...)` or `allocateFromIdle(...)`. The caller supplies adapter addresses; the SDK resolves the allocator from the chain registry. Each call passes the vault's configured WAD-scaled `uint64 penalty`; the allocator pulls `ceil(assets × penalty / WAD)` of the target loan token from Bundler3 and donates it directly to the vault. Its canonical ABI export is `vaultV2BluePublicAllocatorAbi`. - **VaultExitBundlesV1** — standalone periphery for exiting an illiquid VaultV1 or single-adapter VaultV2 into idle underlying assets and/or Morpho Blue supply positions. +- **Shared-liquidity naming** — `VaultV1ReallocationData`, `InputVaultV1ReallocationData`, `computeVaultV1Reallocations`, and `VaultV1Reallocation` are canonical for PublicAllocator V1. Their published predecessors (`ReallocationData`, `InputReallocationData`, `computeReallocations`, `getMarketPublicReallocations`, and `VaultReallocation`) remain deprecated aliases. `MorphoBlue.getVaultV1ReallocationData` is the canonical V1 fetcher; unversioned `getReallocationData` is its deprecated alias. `MorphoBlue.getVaultV2BlueReallocationData` fetches the Blue-specific V2 snapshot. `VaultV2BlueReallocationData.computeVaultV2BlueReallocations` discovers every friendly call by default or accepts an optional operation to produce an amount-aware plan; both modes return flat, action-ready `VaultV2BlueReallocation` calls and their simulated state. ### Bundler actions @@ -39,7 +41,8 @@ The action verbs an integrator sees in the bundle (`BundlerAction.encode...`): - **`erc20TransferFrom`** — pulls user-approved tokens into the bundler. - **`nativeTransfer` + `wrapNative`** — pair that converts an attached native amount (`tx.value`) into the chain's wNative for a deposit/supply path. - **`forceDeallocate`** — VaultV2 multicall entry that pulls liquidity out of a specific adapter before withdraw/redeem. -- **`reallocateTo`** — `PublicAllocator` call that shifts liquidity between markets in a curator vault before a borrow or a loan-asset withdraw. +- **`reallocateTo`** — PublicAllocator V1 call that shifts liquidity from sorted source markets into the target market. +- **`vaultV2BluePublicAllocatorReallocate` / `vaultV2BluePublicAllocatorAllocateFromIdle`** — BluePublicAllocator calls that move one market source or vault idle liquidity into the enclosing Blue action's target market. Both target the chain's registered allocator, approve the exact loan-token penalty from Bundler3, and carry the configured penalty rate in calldata. ### Constants and conventions diff --git a/packages/morpho-sdk/BUNDLER3.md b/packages/morpho-sdk/BUNDLER3.md index 849a9e1a9..f2fa3080d 100644 --- a/packages/morpho-sdk/BUNDLER3.md +++ b/packages/morpho-sdk/BUNDLER3.md @@ -15,20 +15,22 @@ Instead of exposing the user directly to target contracts (ERC-4626 vault, Morph - receives the user's ERC20 tokens (`erc20TransferFrom`, `permit`, `approve2` / `transferFrom2`), - wraps native into wNative (`nativeTransfer` + `wrapNative`), - calls `erc4626Deposit(vault, assets, maxSharePrice, recipient)` enforcing `maxSharePrice` **on-chain**, -- executes `morphoSupplyCollateral`, `morphoBorrow`, `morphoRepay`, `morphoWithdrawCollateral` on Morpho Blue, -- forwards `reallocateTo` calls to the `PublicAllocator` for shared liquidity. +- executes `morphoSupplyCollateral`, `morphoBorrow`, `morphoRepay`, `morphoWithdrawCollateral` on Morpho Blue on the user's behalf. -The **spender** of every approval / permit / permit2 is therefore **always** `generalAdapter1`, never the vault or Morpho directly. See [src/actions/requirements/getRequirements.ts](src/actions/requirements/getRequirements.ts) and the "Requirements System" section of [ARCHITECTURE.md](ARCHITECTURE.md#requirements-system). +Bundler3 also calls allocator contracts directly for shared liquidity: `reallocateTo` on Public +Allocator V1 and `reallocate` or `allocateFromIdle` on Blue Public Allocator. + +The spender of every **user-supplied** approval / permit / permit2 is `generalAdapter1`, never the vault or Morpho directly. Blue Public Allocator penalties add a separate internal allowance: Bundler3 approves the allocator for each exact penalty amount immediately before the non-skippable allocator call. See [`getGeneralAdapterRequirements`](src/actions/requirements/generalAdapter/getGeneralAdapterRequirements.ts) and the "Requirements System" section of [ARCHITECTURE.md](ARCHITECTURE.md#requirements-system). ## Composability & modularity The value of the Bundler3 + GeneralAdapter1 pairing rests on three properties: -1. **Composition of elementary actions.** Each step (`nativeTransfer`, `wrapNative`, `erc20TransferFrom`, `permit`, `approve2`, `transferFrom2`, `erc4626Deposit`, `morphoSupplyCollateral`, `morphoBorrow`, `morphoRepay`, `morphoWithdrawCollateral`, `reallocateTo`) is an independent building block. The SDK **composes** them in an explicit order to build a business flow. +1. **Composition of elementary actions.** Each step (`nativeTransfer`, `wrapNative`, `erc20TransferFrom`, `permit`, `approve2`, `transferFrom2`, `erc4626Deposit`, `morphoSupplyCollateral`, `morphoBorrow`, `morphoRepay`, `morphoWithdrawCollateral`, `reallocateTo`, `vaultV2BluePublicAllocatorReallocate`, `vaultV2BluePublicAllocatorAllocateFromIdle`) is an independent building block. The SDK **composes** them in an explicit order to build a business flow. 2. **Atomicity.** The entire bundle either succeeds or reverts as one. No intermediate state is exposed to MEV bots or other transactions. 3. **Simplified approval UX.** A user approves _a single spender_ (GeneralAdapter1) for the entire protocol surface — rather than one approval per V1/V2 vault or per Morpho contract. -Concretely, `blueSupplyCollateralBorrow` is not a new contract: it is simply the composition `erc20TransferFrom` + `morphoSupplyCollateral` + `morphoBorrow` inside a single bundle. Same story for `repayWithdrawCollateral`, or for a borrow that must first trigger `reallocateTo` calls through the PublicAllocator. The business logic lives in the **order and selection of actions**, not in a dedicated contract. +Concretely, `blueSupplyCollateralBorrow` is not a new contract: it is simply the composition `erc20TransferFrom` + `morphoSupplyCollateral` + `morphoBorrow` inside a single bundle. Same story for `repayWithdrawCollateral`, or for a borrow that must first trigger Public Allocator V1 or Blue Public Allocator calls. The business logic lives in the **order and selection of actions**, not in a dedicated contract. ## Flows overview @@ -37,8 +39,10 @@ Concretely, `blueSupplyCollateralBorrow` is not a new contract: it is simply the | VaultV1 `deposit` | Bundler3 → GeneralAdapter1 | _(opt)_ `nativeTransfer` + `wrapNative` → `erc20TransferFrom` / `permit` / `approve2`+`transferFrom2` → `erc4626Deposit` | | VaultV2 `deposit` | Bundler3 → GeneralAdapter1 | same as VaultV1 | | Blue `supplyCollateral` | Bundler3 → GeneralAdapter1 | _(opt)_ `nativeTransfer` + `wrapNative` → `erc20TransferFrom` → `morphoSupplyCollateral` | -| Blue `borrow` | Bundler3 → GeneralAdapter1 | _(opt)_ `reallocateTo`×N → `morphoBorrow` _(requires `setAuthorization` for GA1 on Morpho)_ | -| Blue `supplyCollateralBorrow` | Bundler3 → GeneralAdapter1 | `erc20TransferFrom` → `morphoSupplyCollateral` → _(opt)_ `reallocateTo`×N → `morphoBorrow` | +| Blue `borrow` | Bundler3 → GeneralAdapter1 | _(opt)_ allocator reallocations → `morphoBorrow` _(requires `setAuthorization` for GA1 on Morpho)_ | +| Blue `supplyCollateralBorrow` | Bundler3 → GeneralAdapter1 | `erc20TransferFrom` → `morphoSupplyCollateral` → _(opt)_ allocator reallocations → `morphoBorrow` | +| Blue `withdraw` | Bundler3 → GeneralAdapter1 | _(opt)_ allocator reallocations → `morphoWithdraw` | +| Blue `refinance` | Bundler3 → GeneralAdapter1 | _(opt)_ target allocator reallocations → `morphoSupplyCollateral` with the borrow/repay/withdraw callback | | Blue `repay` | Bundler3 → GeneralAdapter1 | `erc20TransferFrom` → `morphoRepay` (by `assets` or by `shares`) | | Blue `repayWithdrawCollateral` | Bundler3 → GeneralAdapter1 | `erc20TransferFrom` → `morphoRepay` → `morphoWithdrawCollateral` _(repay **before** withdraw, order is critical)_ | | VaultV1 `withdraw` / `redeem` | **Direct vault call** | _(no bundler, no adapter)_ | @@ -66,11 +70,22 @@ For every ERC-4626 deposit (VaultV1 / VaultV2), GeneralAdapter1 calls `erc4626De ### 4. Shared liquidity without an ad-hoc contract -`VaultReallocation`s are encoded as plain `reallocateTo` bundler actions (PublicAllocator). They are **prepended to the bundle** (borrow) or **inserted between supply-collateral and borrow** (`supplyCollateralBorrow`), and `BundlerAction.encodeBundle` aggregates native fees into `tx.value`. No extra off-chain machinery: everything flows through the same bundler-action composition. +`BlueReallocationPlan` encodes either Public Allocator V1 `reallocateTo` calls or Blue Public +Allocator `reallocate`/`allocateFromIdle` calls. A plan cannot mix allocator versions. Reallocations +are **prepended to the +bundle** for borrow and loan-asset withdraw, **inserted between supply-collateral and borrow** for +`supplyCollateralBorrow`, and run **before the supply-collateral callback** for `blueRefinance`. +`BundlerAction.encodeBundle` includes Public Allocator V1 fees in `tx.value`. Blue Public Allocator +penalties are different: the bundle pulls the aggregate amount in the target loan token through +GeneralAdapter1, approves each exact per-call amount from Bundler3, and lets the allocator donate +it directly to the vault. The entity's `getRequirements()` returns the corresponding classic +loan-token approval when a V2 penalty is non-zero, except when `supplyCollateralBorrow` uses the +same collateral and loan token: that path folds the penalty into its single collateral approval or +permit and emits no separate penalty requirement. -### 5. A single approval surface +### 5. A single user approval surface -Whether it's a V1 deposit, a V2 deposit, a `supplyCollateral`, a `repay`, or a `supplyCollateralBorrow`: the spender is **always** `generalAdapter1`. A user who has already approved GA1 for a given token transparently reuses that approval. The approval / permit / permit2 decision is centralized in [`getRequirements`](src/actions/requirements/getRequirements.ts). +Whether it's a V1 deposit, a V2 deposit, a `supplyCollateral`, a `repay`, or a `supplyCollateralBorrow`, the spender presented to the user is **always** `generalAdapter1`. A user who has already approved GA1 for a given token transparently reuses that approval. The approval / permit / permit2 decision is centralized in [`getGeneralAdapterRequirements`](src/actions/requirements/generalAdapter/getGeneralAdapterRequirements.ts). For Blue Public Allocator penalties, Bundler3 separately grants the allocator an exact per-call allowance; that approval and allocator call cannot be made independently skippable. ## Dangers & limits @@ -101,7 +116,7 @@ This is the main design caveat. For the following operations the SDK emits a **d - **Blue authorization for GA1 required for `borrow`, `supplyCollateralBorrow`, `repayWithdrawCollateral`.** A user who has never granted it will receive a requirement through [`getBlueAuthorizationRequirement`](src/actions/requirements/blue/getBlueAuthorizationRequirement.ts). Without signature support, this is a `setAuthorization` transaction to execute beforehand. With `supportSignature`, this is a signable requirement; pass the resulting `AuthorizationRequirementSignature` to `buildTx`, which folds it into the bundle as `setAuthorizationWithSig`. - **Critical order in `repayWithdrawCollateral`**: `morphoRepay` **must** precede `morphoWithdrawCollateral` in the bundle, otherwise the position is deemed unhealthy at withdraw time and the tx reverts. - **Builder must equal signer.** Bundler actions reference accounts in two different ways: some take an explicit `onBehalf` and act on `userAddress` (e.g. `morphoRepay`), others act implicitly on the **initiator** — the `msg.sender` of `bundler3.multicall`, i.e. the EOA signing the tx, not the adapter — (e.g. `erc20TransferFrom`, `morphoWithdrawCollateral`, the latter exposing no `onBehalf` parameter on GA1). `repayWithdrawCollateral` is the canonical example: the repay leg targets `userAddress` while the transfer-from and the withdraw target the initiator. If the address that built the tx (and filled `userAddress`) is not the address that signs/executes it, the bundle would repay one account's debt while pulling tokens from and withdrawing collateral against the signer. Transaction builders do not validate this at build time — callers MUST keep `userAddress` aligned with the signing account. The signature requirements (`encodeErc20Permit` / `encodeErc20Permit2Approve`) take a `WalletClient` and enforce this at `sign()` time via `validateUserAddress` (throws `MissingClientPropertyError` / `AddressMismatchError`). -- **Tricky `tx.value`**: whenever a `nativeAmount` or a `reallocateTo` (native fee) is involved, `BundlerAction.encodeBundle` computes `tx.value`. Do not overwrite it on the caller side. +- **Tricky `tx.value`**: `BundlerAction.encodeBundle` computes native value for `nativeAmount` and Public Allocator V1 `reallocateTo` fees. Blue Public Allocator penalties are ERC-20 loan-token amounts and never contribute to `tx.value`. Do not overwrite the encoded value on the caller side. - **Chain-specific Bundler3 address**: always resolve through `getChainAddresses(chainId)` and validate that the viem client's `chainId` matches the params. ## Code references diff --git a/packages/morpho-sdk/README.md b/packages/morpho-sdk/README.md index d3728be6c..6440e26eb 100644 --- a/packages/morpho-sdk/README.md +++ b/packages/morpho-sdk/README.md @@ -234,12 +234,13 @@ graph LR M1S -->|nativeWrap? + erc20TransferFrom + morphoSupply| B3[Bundler3] M1SC -->|erc20TransferFrom + morphoSupplyCollateral| B3 - M1B -->|reallocateTo? + morphoBorrow| B3 - M1SCB -->|transfer + supplyCollateral + reallocateTo? + borrow| B3 - M1W -->|reallocateTo? + morphoWithdraw| B3 - M1RF -->|reallocateTo? + supplyCollateral callback: borrow + repay + withdrawCollateral| B3 + M1B -->|allocator reallocation? + morphoBorrow| B3 + M1SCB -->|transfer + supplyCollateral + allocator reallocation? + borrow| B3 + M1W -->|allocator reallocation? + morphoWithdraw| B3 + M1RF -->|allocator reallocation? + supplyCollateral callback: borrow + repay + withdrawCollateral| B3 - B3 -.->|reallocateTo| PA[PublicAllocator] + B3 -.->|reallocateTo| PA1[PublicAllocator V1] + B3 -.->|reallocate / allocateFromIdle| BPA[Blue Public Allocator] end subgraph Midnight Flow @@ -268,7 +269,8 @@ graph LR style MM fill:#fff3e0,stroke:#ff9800 style V2C fill:#e3f2fd,stroke:#2196f3 style REQ fill:#f3e5f5,stroke:#9c27b0 - style PA fill:#fff9c4,stroke:#f9a825 + style PA1 fill:#fff9c4,stroke:#f9a825 + style BPA fill:#fff9c4,stroke:#f9a825 ``` ## Development diff --git a/packages/morpho-sdk/src/abis.ts b/packages/morpho-sdk/src/abis.ts index 69467b3a8..76da26a7f 100644 --- a/packages/morpho-sdk/src/abis.ts +++ b/packages/morpho-sdk/src/abis.ts @@ -22,7 +22,9 @@ export { publicAllocatorAbi, vaultV1AdapterAbi, vaultV1AdapterFactoryAbi, + vaultV1PublicAllocatorAbi, vaultV2Abi, + vaultV2BluePublicAllocatorAbi, vaultV2FactoryAbi, whitelistControllerAggregatorV2Abi, wrappedBackedTokenAbi, diff --git a/packages/morpho-sdk/src/actions/AGENTS.md b/packages/morpho-sdk/src/actions/AGENTS.md index d46293bd8..80ac4f725 100644 --- a/packages/morpho-sdk/src/actions/AGENTS.md +++ b/packages/morpho-sdk/src/actions/AGENTS.md @@ -24,7 +24,7 @@ Only valid for assets/collateral configured as wNative. When `nativeAmount > 0`: ## Shared liquidity / reallocations (canonical statement) -`blueBorrow` and `blueSupplyCollateralBorrow` accept optional `reallocations: VaultReallocation[]`. Each reallocation becomes a `PublicAllocator.reallocateTo(vault, fee, withdrawals, targetMarket)` bundler action **before** `morphoBorrow`. `BundlerAction.encodeBundle` includes those fees in `tx.value`. Validation: `helpers/validateReallocations`. Other layer docs link here rather than restating these rules. +`blueBorrow`, `blueSupplyCollateralBorrow`, loan-asset `blueWithdraw`, and refinance target flows accept an optional homogeneous `BlueReallocationPlan` (refinance names the field `targetReallocations`). `VaultV1Reallocation` entries become `reallocateTo(vault, fee, sortedWithdrawals, targetMarket)` before the primary Blue action; `VaultReallocation` remains a deprecated alias. `VaultV2BlueReallocation` entries map 1:1 to `reallocate(...)` for a market source or `allocateFromIdle(...)` for idle liquidity; the enclosing action supplies the target market, the input supplies adapters, the chain registry supplies the allocator, and each call passes the vault's configured WAD-scaled `penalty`. Mixing allocator versions throws `MixedReallocationVersionsError`. BluePublicAllocator sources are not sorted and idle uses no synthetic zero-address market. High-level builders pull the aggregate V2 penalty in the target loan token through GeneralAdapter1, then each low-level allocator action approves and spends its independently rounded `ceil(assets × penalty / WAD)` amount from Bundler3. Only V1 fees contribute to `tx.value`; all high-level allocator calls use `skipRevert: false`. Normalization dispatches to the separate V1 and V2 validators and action builders. ## Discriminated unions diff --git a/packages/morpho-sdk/src/actions/blue/AGENTS.md b/packages/morpho-sdk/src/actions/blue/AGENTS.md index 775e8b12e..dadf0062b 100644 --- a/packages/morpho-sdk/src/actions/blue/AGENTS.md +++ b/packages/morpho-sdk/src/actions/blue/AGENTS.md @@ -28,17 +28,24 @@ ERC-20 approval spender is **GeneralAdapter1** for any bundled path — never th | `supplyCollateral` (ERC-20) | `erc20TransferFrom → morphoSupplyCollateral` | | `supplyCollateral` (native) | `nativeTransfer → wrapNative → [erc20TransferFrom?] → morphoSupplyCollateral` | | `borrow` | `morphoBorrow` | -| `borrow` (with reallocations) | `[reallocateTo × N] → morphoBorrow` | +| `borrow` (with reallocations) | `[V2 penalty transfer?] → [allocator reallocation × N] → morphoBorrow` | | `supplyCollateralBorrow` | `[nativeWrap?] → [erc20Transfer?] → morphoSupplyCollateral → morphoBorrow` | -| `supplyCollateralBorrow` (with reallocations) | `[nativeWrap?] → [erc20Transfer?] → morphoSupplyCollateral → [reallocateTo × N] → morphoBorrow` | +| `supplyCollateralBorrow` (with reallocations) | `[nativeWrap?] → [collateral transfer?] → morphoSupplyCollateral → [V2 penalty transfer?] → [allocator reallocation × N] → morphoBorrow` | | `repay` (ERC-20) | `[erc20TransferFrom \| permit/permit2] → morphoRepay → [erc20Transfer skim (shares mode)]` | | `repay` (native) | `nativeTransfer → wrapNative → [erc20TransferFrom?] → morphoRepay → [skim (shares mode)]` | | `repayWithdrawCollateral` (ERC-20) | `[erc20TransferFrom \| permit/permit2] → morphoRepay → [skim (shares mode)] → morphoWithdrawCollateral` | | `repayWithdrawCollateral` (native) | `nativeTransfer → wrapNative → [erc20TransferFrom?] → morphoRepay → [skim (shares mode)] → morphoWithdrawCollateral` | | `withdraw` | `morphoWithdraw` | -| `withdraw` (with reallocations) | `[reallocateTo × N] → morphoWithdraw` | +| `withdraw` (with reallocations) | `[V2 penalty transfer?] → [allocator reallocation × N] → morphoWithdraw` | -`BundlerAction.encodeBundle` derives `tx.value` from native wrapping calls and reallocation fees. +An allocator reallocation plan contains only PublicAllocator V1 `reallocateTo` calls or only +BluePublicAllocator `reallocate`/`allocateFromIdle` calls. Separate builders encode each version; +mixing versions throws `MixedReallocationVersionsError`. For non-zero V2 penalties, the V2 builder +adds one aggregate loan-token funding action into Bundler3: `erc20TransferFrom` from the initiator by +default, or `erc20Transfer` from GeneralAdapter1 when `supplyCollateralBorrow` uses the same token for +collateral and loan funding. Each allocator action expands to an exact token approval plus the +nonpayable allocator call. `BundlerAction.encodeBundle` derives `tx.value` only from native wrapping +calls and PublicAllocator V1 native fees. ## Mode and ordering rules diff --git a/packages/morpho-sdk/src/actions/blue/borrow.bluePublicAllocator.test.ts b/packages/morpho-sdk/src/actions/blue/borrow.bluePublicAllocator.test.ts new file mode 100644 index 000000000..dca75942d --- /dev/null +++ b/packages/morpho-sdk/src/actions/blue/borrow.bluePublicAllocator.test.ts @@ -0,0 +1,228 @@ +import { ChainId, getChainAddresses, MarketParams } from "@morpho-org/blue-sdk"; +import { vaultV2BluePublicAllocatorAbi as canonicalVaultV2BluePublicAllocatorAbi } from "@morpho-org/blue-sdk-viem"; +import { decodeFunctionData, erc20Abi } from "viem"; +import { describe, expect, test } from "vitest"; +import { + bundler3Abi, + generalAdapter1Abi, + publicAllocatorAbi, + vaultV1PublicAllocatorAbi, + vaultV2BluePublicAllocatorAbi, +} from "../../abis.js"; +import { MAX_REALLOCATION_PENALTY } from "../../helpers/constant.js"; +import { + type BlueReallocationPlan, + InconsistentReallocationPenaltyError, + InputExceedsMaxError, + MixedReallocationVersionsError, + type VaultV2BlueReallocation, +} from "../../types/index.js"; +import { blueBorrow } from "./borrow.js"; + +const allocator = getChainAddresses( + ChainId.EthMainnet, +).vaultV2BluePublicAllocator; +const vaultV1 = "0x0000000000000000000000000000000000000012"; +const sourceAdapter = "0x0000000000000000000000000000000000000013"; +const targetAdapter = "0x0000000000000000000000000000000000000014"; +const receiver = "0x0000000000000000000000000000000000000015"; +const vaultV2 = "0x0000000000000000000000000000000000000016"; + +const targetMarket = new MarketParams({ + loanToken: "0x0000000000000000000000000000000000000021", + collateralToken: "0x0000000000000000000000000000000000000022", + oracle: "0x0000000000000000000000000000000000000023", + irm: "0x0000000000000000000000000000000000000024", + lltv: 860_000_000_000_000_000n, +}); + +const sourceMarket = new MarketParams({ + loanToken: targetMarket.loanToken, + collateralToken: "0x0000000000000000000000000000000000000032", + oracle: "0x0000000000000000000000000000000000000033", + irm: targetMarket.irm, + lltv: targetMarket.lltv, +}); + +describe("blueBorrow Blue Public Allocator", () => { + test("default", () => { + const { + bundler3: { bundler3 }, + } = getChainAddresses(ChainId.EthMainnet); + const reallocations: readonly VaultV2BlueReallocation[] = [ + { + vault: vaultV2, + from: { + type: "market", + adapter: sourceAdapter, + marketParams: sourceMarket, + }, + to: { adapter: targetAdapter }, + assets: 3n, + penalty: 5n, + }, + { + vault: vaultV2, + from: { type: "idle" }, + to: { adapter: targetAdapter }, + assets: 7n, + penalty: 5n, + }, + ]; + + const tx = blueBorrow({ + market: { chainId: ChainId.EthMainnet, marketParams: targetMarket }, + args: { + amount: 1n, + minSharePrice: 0n, + receiver, + reallocations: reallocations.values(), + }, + }); + + expect(tx.value).toBe(0n); + expect(tx.action.args.reallocationFee).toBe(0n); + expect(tx.action.args.reallocationPenaltyAssets).toBe(2n); + + const bundle = decodeFunctionData({ abi: bundler3Abi, data: tx.data }); + const calls = bundle.args[0] ?? []; + expect(calls).toHaveLength(6); + expect(calls.map((call) => call.value)).toEqual([0n, 0n, 0n, 0n, 0n, 0n]); + expect(calls.every((call) => call.skipRevert === false)).toBe(true); + + expect( + decodeFunctionData({ abi: generalAdapter1Abi, data: calls[0]!.data }), + ).toMatchObject({ + functionName: "erc20TransferFrom", + args: [targetMarket.loanToken, bundler3, 2n], + }); + + expect( + decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: calls[2]!.data, + }).functionName, + ).toBe("reallocate"); + expect( + decodeFunctionData({ abi: erc20Abi, data: calls[1]!.data }), + ).toMatchObject({ functionName: "approve", args: [allocator, 1n] }); + + const idleCall = decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: calls[4]!.data, + }); + expect(idleCall.functionName).toBe("allocateFromIdle"); + expect(idleCall.args[0]).toBe(vaultV2); + expect(idleCall.args[1]).toBe(targetAdapter); + expect(idleCall.args[2]).toMatchObject({ + loanToken: targetMarket.loanToken, + collateralToken: targetMarket.collateralToken, + oracle: targetMarket.oracle, + irm: targetMarket.irm, + lltv: targetMarket.lltv, + }); + expect(idleCall.args[3]).toBe(7n); + expect(idleCall.args[4]).toBe(5n); + expect( + decodeFunctionData({ abi: erc20Abi, data: calls[3]!.data }), + ).toMatchObject({ functionName: "approve", args: [allocator, 1n] }); + expect( + decodeFunctionData({ abi: generalAdapter1Abi, data: calls[5]!.data }) + .functionName, + ).toBe("morphoBorrow"); + }); + + test("error: MixedReallocationVersionsError", () => { + const reallocations = [ + { + vault: vaultV1, + fee: 2n, + withdrawals: [{ marketParams: sourceMarket, amount: 1n }], + }, + { + vault: vaultV2, + from: { type: "idle" }, + to: { adapter: targetAdapter }, + assets: 7n, + penalty: 5n, + }, + ] as unknown as BlueReallocationPlan; + + expect(() => + blueBorrow({ + market: { chainId: ChainId.EthMainnet, marketParams: targetMarket }, + args: { + amount: 1n, + minSharePrice: 0n, + receiver, + reallocations, + }, + }), + ).toThrow(MixedReallocationVersionsError); + }); + + test("error: InconsistentReallocationPenaltyError", () => { + expect(() => + blueBorrow({ + market: { chainId: ChainId.EthMainnet, marketParams: targetMarket }, + args: { + amount: 1n, + minSharePrice: 0n, + receiver, + reallocations: [ + { + vault: vaultV2, + from: { + type: "market", + adapter: sourceAdapter, + marketParams: sourceMarket, + }, + to: { adapter: targetAdapter }, + assets: 3n, + penalty: 5n, + }, + { + vault: vaultV2, + from: { type: "idle" }, + to: { adapter: targetAdapter }, + assets: 7n, + penalty: 11n, + }, + ], + }, + }), + ).toThrow(InconsistentReallocationPenaltyError); + }); + + test("error: InputExceedsMaxError for a penalty above WAD", () => { + expect(() => + blueBorrow({ + market: { chainId: ChainId.EthMainnet, marketParams: targetMarket }, + args: { + amount: 1n, + minSharePrice: 0n, + receiver, + reallocations: [ + { + vault: vaultV2, + from: { type: "idle" }, + to: { adapter: targetAdapter }, + assets: 1n, + penalty: MAX_REALLOCATION_PENALTY + 1n, + }, + ], + }, + }), + ).toThrow(InputExceedsMaxError); + }); + + test("re-exports the canonical ABI", () => { + expect(vaultV2BluePublicAllocatorAbi).toBe( + canonicalVaultV2BluePublicAllocatorAbi, + ); + }); + + test("keeps the deprecated Vault V1 PublicAllocator ABI alias", () => { + expect(publicAllocatorAbi).toBe(vaultV1PublicAllocatorAbi); + }); +}); diff --git a/packages/morpho-sdk/src/actions/blue/borrow.ts b/packages/morpho-sdk/src/actions/blue/borrow.ts index 8176d6d94..46eb45d1f 100644 --- a/packages/morpho-sdk/src/actions/blue/borrow.ts +++ b/packages/morpho-sdk/src/actions/blue/borrow.ts @@ -6,14 +6,14 @@ import { addTransactionMetadata } from "../../helpers/index.js"; import { type AuthorizationRequirementSignature, type BlueBorrowAction, + type BlueReallocationPlan, type Metadata, NegativeInputError, NonPositiveInputError, type Transaction, - type VaultReallocation, } from "../../types/index.js"; import { getBlueAuthorizationAction } from "../signatures/getBlueAuthorizationAction.js"; -import { buildReallocationActions } from "./buildReallocationActions.js"; +import { buildBlueReallocationActions } from "./buildReallocationActions.js"; /** Parameters for {@link blueBorrow}. */ export interface BlueBorrowParams { @@ -28,8 +28,8 @@ export interface BlueBorrowParams { receiver: Address; /** Minimum borrow share price (in ray). Protects against share price manipulation. */ minSharePrice: bigint; - /** Vault reallocations to execute before borrowing (computed by entity). */ - reallocations?: readonly VaultReallocation[]; + /** Homogeneous Vault V1 or Vault V2 reallocations to execute before borrowing. */ + reallocations?: BlueReallocationPlan; /** * Optional signed Morpho authorization. When provided, a `setAuthorizationWithSig` call is * prepended to the bundle so GeneralAdapter1 is authorized in-bundle instead of via a @@ -47,17 +47,18 @@ export interface BlueBorrowParams { * `onBehalf`. Uses `minSharePrice` to protect against share price manipulation between * transaction construction and execution. * - * When `reallocations` are provided, `reallocateTo` actions are prepended to the bundle, moving - * liquidity from other markets via the PublicAllocator before borrowing. Reallocation fees - * accumulate in `tx.value`. + * A `reallocations` plan contains either PublicAllocator V1 entries or Vault V2 + * BluePublicAllocator entries, never both. The calls run before the borrow. + * V1 fees accumulate in `tx.value`; V2 penalties are paid in the target loan + * token and donated directly to each vault. * * @param params.market.chainId - The chain the market lives on. * @param params.market.marketParams - Market params (loanToken, collateralToken, oracle, irm, lltv). * @param params.args.amount - Loan asset amount to borrow, in the loan token's smallest unit. * @param params.args.receiver - Address that receives the borrowed assets. * @param params.args.minSharePrice - Minimum borrow share price (in ray). Slippage protection. - * @param params.args.reallocations - Optional vault reallocations to execute before borrowing, - * computed by the entity layer. + * @param params.args.reallocations - Optional homogeneous Vault V1 or Vault V2 reallocations to + * execute before borrowing. * @param params.args.authorizationSignature - Optional signed Morpho authorization; when present, * a `setAuthorizationWithSig` call is prepended to the bundle. * @param params.metadata - Optional analytics metadata attached to the bundle. @@ -65,13 +66,18 @@ export interface BlueBorrowParams { * typed `action` discriminator the simulation layer consumes. * @throws {NonPositiveInputError} when `amount <= 0n` or any reallocation withdrawal amount * is non-positive. - * @throws {NegativeInputError} when `minSharePrice < 0n` or any reallocation fee is negative. - * @throws {EmptyReallocationWithdrawalsError} from `buildReallocationActions` when any - * `reallocation.withdrawals` is empty. - * @throws {ReallocationWithdrawalOnTargetMarketError} from `buildReallocationActions` when any - * reallocation withdrawal references the target market. - * @throws {UnsortedReallocationWithdrawalsError} from `buildReallocationActions` when - * reallocation withdrawals are not strictly sorted by market id. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. + * @throws {NegativeInputError} when `minSharePrice < 0n`, a V1 fee, or a V2 penalty is negative. + * @throws {EmptyReallocationWithdrawalsError} when any `reallocation.withdrawals` is empty. + * @throws {ReallocationWithdrawalOnTargetMarketError} when any reallocation withdrawal references + * the target market. + * @throws {UnsortedReallocationWithdrawalsError} when reallocation withdrawals are not strictly + * sorted by market id. * @example * ```ts * import { blueBorrow } from "@morpho-org/morpho-sdk"; @@ -107,17 +113,21 @@ export const blueBorrow = ({ } const actions: Action[] = []; - let reallocationFee = 0n; if (authorizationSignature) { actions.push(getBlueAuthorizationAction(chainId, authorizationSignature)); } - if (reallocations && reallocations.length > 0) { - const result = buildReallocationActions(reallocations, marketParams); - actions.push(...result.actions); - reallocationFee = result.fee; - } + const { + actions: reallocationActions, + fee: reallocationFee, + penaltyAssets: reallocationPenaltyAssets, + } = buildBlueReallocationActions({ + chainId, + reallocations, + targetMarketParams: marketParams, + }); + actions.push(...reallocationActions); actions.push({ type: "morphoBorrow", @@ -140,6 +150,7 @@ export const blueBorrow = ({ receiver, minSharePrice, reallocationFee, + reallocationPenaltyAssets, }, }, }); diff --git a/packages/morpho-sdk/src/actions/blue/buildAssetFundingActions.ts b/packages/morpho-sdk/src/actions/blue/buildAssetFundingActions.ts index ed3b53d6b..a3f7f2641 100644 --- a/packages/morpho-sdk/src/actions/blue/buildAssetFundingActions.ts +++ b/packages/morpho-sdk/src/actions/blue/buildAssetFundingActions.ts @@ -25,7 +25,7 @@ export interface BuildAssetFundingActionsParams { * `blueSupplyCollateral`, `blueSupplyCollateralBorrow`, `blueRepay`, `blueRepayWithdrawCollateral`). * * Encode-only and synchronous: returns a fresh `Action[]` (never mutates its inputs, mirroring - * `buildReallocationActions`) and reads no on-chain state. When `nativeAmount > 0n`, validates + * `buildVaultV2BlueReallocationActions`) and reads no on-chain state. When `nativeAmount > 0n`, validates * `asset` is the chain's wNative and emits `nativeTransfer → wrapNative`. When `erc20Amount > 0n`, * pulls the ERC-20 via a signed permit/permit2 (`requirementSignature`) or a plain * `erc20TransferFrom` — `getTokenRequirementActions` emits the latter itself when no signature is diff --git a/packages/morpho-sdk/src/actions/blue/buildReallocationActions.ts b/packages/morpho-sdk/src/actions/blue/buildReallocationActions.ts index 991a1f66a..21f534bc3 100644 --- a/packages/morpho-sdk/src/actions/blue/buildReallocationActions.ts +++ b/packages/morpho-sdk/src/actions/blue/buildReallocationActions.ts @@ -1,51 +1,193 @@ -import type { MarketParams } from "@morpho-org/blue-sdk"; +import { + getChainAddresses, + type MarketParams, + VaultV2BluePublicAllocatorConfigUtils, +} from "@morpho-org/blue-sdk"; import type { Action } from "../../bundler/index.js"; -import { validateReallocations } from "../../helpers/index.js"; -import type { VaultReallocation } from "../../types/index.js"; +import { validateAndNormalizeReallocations } from "../../helpers/validate.js"; +import type { + BlueReallocationPlan, + VaultV1Reallocation, + VaultV2BlueReallocation, +} from "../../types/index.js"; /** - * Builds reallocation bundler actions and computes the total fee. + * Builds PublicAllocator V1 reallocation actions for a Morpho Blue target market. * - * Validates the reallocations, then encodes each as a `reallocateTo` action against the target - * market. Returns `{ actions: [], fee: 0n }` for an empty input — callers gate on - * `reallocations.length > 0` and skip the call entirely in that case. Internal helper — - * consumed by the Blue actions that accept reallocations; not re-exported on the public surface. + * Preserves the supplied reallocation order and aggregates each allocator call's native fee. * - * @param reallocations - The vault reallocations to encode. - * @param targetMarketParams - The target market params the freed liquidity is destined for. - * @returns The encoded `reallocateTo` actions and the summed reallocation fee in native tokens. - * @throws {NegativeInputError} when any reallocation fee is negative. - * @throws {EmptyReallocationWithdrawalsError} when any reallocation has no withdrawals. - * @throws {NonPositiveInputError} when any withdrawal amount is non-positive. - * @throws {ReallocationWithdrawalOnTargetMarketError} when a withdrawal references the target market. - * @throws {UnsortedReallocationWithdrawalsError} when withdrawals within a reallocation are not - * strictly sorted by market id. + * @param params.reallocations - Validated Vault V1 reallocations to execute. + * @param params.targetMarketParams - Morpho Blue market receiving the reallocated liquidity. + * @returns The reallocation actions and aggregate native fee, with zero V2 penalty assets. * @internal */ -export const buildReallocationActions = ( - reallocations: readonly VaultReallocation[], - targetMarketParams: MarketParams, -): { readonly actions: Action[]; readonly fee: bigint } => { - validateReallocations(reallocations, targetMarketParams.id); - - const fee = reallocations.reduce((sum, r) => sum + r.fee, 0n); +export const buildVaultV1ReallocationActions = ({ + reallocations, + targetMarketParams, +}: { + readonly reallocations: readonly VaultV1Reallocation[]; + readonly targetMarketParams: MarketParams; +}) => { + let fee = 0n; const actions: Action[] = []; - for (const r of reallocations) { + for (const reallocation of reallocations) { actions.push({ type: "reallocateTo", args: [ - r.vault, - r.fee, - r.withdrawals.map((w) => ({ - marketParams: w.marketParams, - amount: w.amount, + reallocation.vault, + reallocation.fee, + reallocation.withdrawals.map((withdrawal) => ({ + marketParams: withdrawal.marketParams, + amount: withdrawal.amount, })), targetMarketParams, false, ], }); + fee += reallocation.fee; + } + + return { actions, fee, penaltyAssets: 0n }; +}; + +/** + * Builds BluePublicAllocator reallocation actions for a Morpho Blue target market. + * + * Prepends one aggregate loan-token funding action when penalties are non-zero, then builds one + * allocator action per validated Vault V2 reallocation. + * + * @param params.chainId - Chain whose registered Bundler3 and allocator addresses are used. + * @param params.reallocations - Validated Vault V2 Blue reallocations to execute. + * @param params.targetMarketParams - Morpho Blue market receiving the reallocated liquidity. + * @param params.penaltyFundingSource - Optional source of penalty assets. Defaults to the + * transaction initiator. + * @returns The funding and reallocation actions and aggregate penalty assets, with zero native fee. + * @internal + */ +export const buildVaultV2BlueReallocationActions = ({ + chainId, + reallocations, + targetMarketParams, + penaltyFundingSource = "initiator", +}: { + readonly chainId: number; + readonly reallocations: readonly VaultV2BlueReallocation[]; + readonly targetMarketParams: MarketParams; + readonly penaltyFundingSource?: "initiator" | "generalAdapter1"; +}) => { + const actions: Action[] = []; + const penaltyAssets = reallocations.reduce( + (total, reallocation) => + total + + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + reallocation, + reallocation.assets, + ), + 0n, + ); + + if (penaltyAssets > 0n) { + const { + bundler3: { bundler3, generalAdapter1 }, + } = getChainAddresses(chainId); + actions.push( + penaltyFundingSource === "generalAdapter1" + ? { + type: "erc20Transfer", + args: [ + targetMarketParams.loanToken, + bundler3, + penaltyAssets, + generalAdapter1, + false, + ], + } + : { + type: "erc20TransferFrom", + args: [ + targetMarketParams.loanToken, + penaltyAssets, + bundler3, + false, + ], + }, + ); } - return { actions, fee }; + for (const reallocation of reallocations) { + actions.push( + reallocation.from.type === "market" + ? { + type: "vaultV2BluePublicAllocatorReallocate", + args: [ + reallocation.vault, + reallocation.from.adapter, + reallocation.from.marketParams, + reallocation.to.adapter, + targetMarketParams, + reallocation.assets, + reallocation.penalty, + false, + ], + } + : { + type: "vaultV2BluePublicAllocatorAllocateFromIdle", + args: [ + reallocation.vault, + reallocation.to.adapter, + targetMarketParams, + reallocation.assets, + reallocation.penalty, + false, + ], + }, + ); + } + + return { actions, fee: 0n, penaltyAssets }; +}; + +/** + * Validates a homogeneous Blue reallocation plan and builds its Bundler actions. + * + * Dispatches Vault V1 and Vault V2 plans to their version-specific builders. V1 plans aggregate + * native fees, while V2 plans aggregate loan-token penalties using the selected funding source. + * + * @param params.chainId - Chain whose registered Bundler3 and allocator addresses are used. + * @param params.reallocations - Optional homogeneous Vault V1 or Vault V2 reallocation plan. + * @param params.targetMarketParams - Morpho Blue market receiving the reallocated liquidity. + * @param params.penaltyFundingSource - Optional source of V2 penalty assets. Defaults to the + * transaction initiator. + * @returns The reallocation actions, aggregate V1 native fee, and aggregate V2 penalty assets. + * @internal + */ +export const buildBlueReallocationActions = ({ + chainId, + reallocations, + targetMarketParams, + penaltyFundingSource, +}: { + readonly chainId: number; + readonly reallocations: BlueReallocationPlan | undefined; + readonly targetMarketParams: MarketParams; + readonly penaltyFundingSource?: "initiator" | "generalAdapter1"; +}) => { + const reallocationPlan = validateAndNormalizeReallocations({ + reallocations, + targetMarketId: targetMarketParams.id, + chainId, + }); + + return reallocationPlan.type === "vaultV1" + ? buildVaultV1ReallocationActions({ + reallocations: reallocationPlan.reallocations, + targetMarketParams, + }) + : buildVaultV2BlueReallocationActions({ + chainId, + reallocations: reallocationPlan.reallocations, + targetMarketParams, + penaltyFundingSource, + }); }; diff --git a/packages/morpho-sdk/src/actions/blue/refinance.test.ts b/packages/morpho-sdk/src/actions/blue/refinance.test.ts index 779c09c44..c79df3cb4 100644 --- a/packages/morpho-sdk/src/actions/blue/refinance.test.ts +++ b/packages/morpho-sdk/src/actions/blue/refinance.test.ts @@ -1,7 +1,19 @@ import { getChainAddresses, MarketParams } from "@morpho-org/blue-sdk"; -import { type Address, maxUint256, parseUnits, toFunctionSelector } from "viem"; +import { + type Address, + decodeFunctionData, + erc20Abi, + maxUint256, + parseUnits, + toFunctionSelector, +} from "viem"; import { mainnet } from "viem/chains"; import { describe, expect, test } from "vitest"; +import { + bundler3Abi, + generalAdapter1Abi, + vaultV2BluePublicAllocatorAbi, +} from "../../abis.js"; import { NegativeInputError, NonPositiveInputError, @@ -10,6 +22,7 @@ import { RefinanceSharesMissingBorrowAssetsError, RefinanceTokenMismatchError, type VaultReallocation, + type VaultV2BlueReallocation, } from "../../types/index.js"; import { blueRefinance } from "./refinance.js"; @@ -381,6 +394,13 @@ describe("blueRefinance", () => { }); const VAULT: Address = "0xBEEf5aFE88eF73337e5070aB2855d37dBF5493A4"; const REALLOC_FEE = parseUnits("0.01", 18); + const V2_ALLOCATOR = getChainAddresses(mainnet.id) + .vaultV2BluePublicAllocator!; + const V2_VAULT: Address = "0x0000000000000000000000000000000000000012"; + const SOURCE_ADAPTER: Address = + "0x0000000000000000000000000000000000000013"; + const TARGET_ADAPTER: Address = + "0x0000000000000000000000000000000000000014"; const makeReallocations = (): readonly VaultReallocation[] => [ { @@ -462,6 +482,88 @@ describe("blueRefinance", () => { expect(reallocVaultIdx).toBeLessThan(supplyIdx); }); + test("behavior: V2 market and idle reallocations fund penalties before the target supply", () => { + const { + bundler3: { bundler3 }, + } = getChainAddresses(mainnet.id); + const targetReallocations: readonly VaultV2BlueReallocation[] = [ + { + vault: V2_VAULT, + from: { + type: "market", + adapter: SOURCE_ADAPTER, + marketParams: reallocSource, + }, + to: { adapter: TARGET_ADAPTER }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + { + vault: V2_VAULT, + from: { type: "idle" }, + to: { adapter: TARGET_ADAPTER }, + assets: 6n, + penalty: 500_000_000_000_000_000n, + }, + ]; + + const tx = blueRefinance({ + source: { chainId: mainnet.id, marketParams: source }, + target: { marketParams: target }, + args: { + ...baseArgs, + borrowAssets: parseUnits("1000", 6), + targetReallocations, + }, + metadata: { origin: "a1b2c3d4" }, + }); + + expect(tx.value).toBe(0n); + expect(tx.action.args.reallocationFee).toBe(0n); + expect(tx.action.args.reallocationPenaltyAssets).toBe(8n); + expect(tx.data).toContain("a1b2c3d4"); + + const bundle = decodeFunctionData({ abi: bundler3Abi, data: tx.data }); + const calls = bundle.args[0] ?? []; + expect(calls).toHaveLength(6); + expect( + decodeFunctionData({ abi: generalAdapter1Abi, data: calls[0]!.data }), + ).toMatchObject({ + functionName: "erc20TransferFrom", + args: [target.loanToken, bundler3, 8n], + }); + expect( + decodeFunctionData({ abi: erc20Abi, data: calls[1]!.data }), + ).toMatchObject({ + functionName: "approve", + args: [V2_ALLOCATOR, 5n], + }); + expect( + decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: calls[2]!.data, + }).functionName, + ).toBe("reallocate"); + expect( + decodeFunctionData({ abi: erc20Abi, data: calls[3]!.data }), + ).toMatchObject({ + functionName: "approve", + args: [V2_ALLOCATOR, 3n], + }); + expect( + decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: calls[4]!.data, + }).functionName, + ).toBe("allocateFromIdle"); + expect( + decodeFunctionData({ + abi: generalAdapter1Abi, + data: calls[5]!.data, + }).functionName, + ).toBe("morphoSupplyCollateral"); + }); + test("behavior: collat-only refinance accepts reallocations", () => { const tx = blueRefinance({ source: { chainId: mainnet.id, marketParams: source }, diff --git a/packages/morpho-sdk/src/actions/blue/refinance.ts b/packages/morpho-sdk/src/actions/blue/refinance.ts index 287f79555..6229e6b20 100644 --- a/packages/morpho-sdk/src/actions/blue/refinance.ts +++ b/packages/morpho-sdk/src/actions/blue/refinance.ts @@ -5,6 +5,7 @@ import { type Action, BundlerAction } from "../../bundler/index.js"; import { addTransactionMetadata } from "../../helpers/index.js"; import { type AuthorizationRequirementSignature, + type BlueReallocationPlan, type BlueRefinanceAction, type Metadata, NegativeInputError, @@ -13,10 +14,9 @@ import { RefinanceSharesMissingBorrowAssetsError, RefinanceTokenMismatchError, type Transaction, - type VaultReallocation, } from "../../types/index.js"; import { getBlueAuthorizationAction } from "../signatures/getBlueAuthorizationAction.js"; -import { buildReallocationActions } from "./buildReallocationActions.js"; +import { buildBlueReallocationActions } from "./buildReallocationActions.js"; /** Parameters for {@link blueRefinance}. */ export interface BlueRefinanceParams { @@ -44,8 +44,8 @@ export interface BlueRefinanceParams { minBorrowSharePrice: bigint; /** Maximum repay share price on the source market (in ray); must be > 0 when a repay leg exists. */ maxRepaySharePrice: bigint; - /** PublicAllocator reallocations into the target market, run before the bundle. Fees add to `tx.value`. */ - targetReallocations?: readonly VaultReallocation[]; + /** Homogeneous Vault V1 or Vault V2 reallocations into the target market. */ + targetReallocations?: BlueReallocationPlan; /** * Optional signed Morpho authorization. When provided, a `setAuthorizationWithSig` call is * prepended to the bundle so GeneralAdapter1 is authorized in-bundle instead of via a @@ -67,8 +67,8 @@ export interface BlueRefinanceParams { * Bundle shape (callback contents depend on borrow mode): * * ```text - * // optional: one reallocateTo per targetReallocations entry, run first - * reallocateTo(vault_i, fee_i, withdrawals_i, target, false), + * // optional targetReallocations run first: + * reallocateTo(...) | reallocate(...) | allocateFromIdle(...), * * morphoSupplyCollateral(target, collateralAmount, user, [ * // omitted in collat-only mode @@ -102,7 +102,8 @@ export interface BlueRefinanceParams { * @param params.args.borrowShares - Borrow shares to repay on the source; exclusive with `borrowAssets`. Defaults to `0n`. * @param params.args.minBorrowSharePrice - Minimum borrow share price (ray) on the target. * @param params.args.maxRepaySharePrice - Maximum repay share price (ray) on the source. - * @param params.args.targetReallocations - PublicAllocator reallocations into the target, run before the supply leg. + * @param params.args.targetReallocations - Homogeneous Vault V1 or Vault V2 reallocations into the + * target, run before the supply leg. V1 fees add to `tx.value`; V2 penalties are paid in the target loan token. * @param params.args.authorizationSignature - Optional signed Morpho authorization; when present, * a `setAuthorizationWithSig` call is prepended to the bundle. * @param params.metadata - Optional analytics metadata appended to `tx.data`. @@ -111,8 +112,14 @@ export interface BlueRefinanceParams { * repay); in shares mode the entity passes both. Caller-facing mutual exclusivity is enforced at the entity layer. * @throws {NonPositiveInputError} when `collateralAmount <= 0n`, a repay leg has a non-positive * `maxRepaySharePrice`, or any reallocation withdrawal amount is non-positive. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. * @throws {NegativeInputError} when `borrowAssets`, `borrowShares`, `minBorrowSharePrice`, - * `maxRepaySharePrice`, or any reallocation fee is negative. + * `maxRepaySharePrice`, a V1 fee, or a V2 penalty is negative. * @throws {RefinanceSameMarketError} when source and target market ids are equal. * @throws {RefinanceTokenMismatchError} when source and target do not share both tokens. * @throws {RefinanceSharesMissingBorrowAssetsError} when `borrowShares > 0n` but `borrowAssets` is omitted or non-positive. @@ -268,17 +275,21 @@ export const blueRefinance = ({ }); const actions: Action[] = []; - let reallocationFee = 0n; if (authorizationSignature) { actions.push(getBlueAuthorizationAction(chainId, authorizationSignature)); } - if (targetReallocations && targetReallocations.length > 0) { - const result = buildReallocationActions(targetReallocations, targetParams); - actions.push(...result.actions); - reallocationFee = result.fee; - } + const { + actions: reallocationActions, + fee: reallocationFee, + penaltyAssets: reallocationPenaltyAssets, + } = buildBlueReallocationActions({ + chainId, + reallocations: targetReallocations, + targetMarketParams: targetParams, + }); + actions.push(...reallocationActions); actions.push({ type: "morphoSupplyCollateral", @@ -305,6 +316,7 @@ export const blueRefinance = ({ maxRepaySharePrice, user, reallocationFee, + reallocationPenaltyAssets, }, }, }); diff --git a/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.test.ts b/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.test.ts index abe40676e..05e213a49 100644 --- a/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.test.ts +++ b/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.test.ts @@ -1,5 +1,9 @@ -import { addressesRegistry, MarketParams } from "@morpho-org/blue-sdk"; -import { parseUnits } from "viem"; +import { + addressesRegistry, + getChainAddresses, + MarketParams, +} from "@morpho-org/blue-sdk"; +import { decodeFunctionData, type Hex, parseUnits } from "viem"; import { mainnet } from "viem/chains"; import { afterEach, describe, expect, vi } from "vitest"; import { @@ -8,14 +12,18 @@ import { WethUsdsBlue, } from "../../../test/fixtures/blue.js"; import { SteakhouseUsdcVaultV1 } from "../../../test/fixtures/vaultV1.js"; +import { makePermit } from "../../../test/helpers/permit.js"; import { test } from "../../../test/setup.js"; +import { bundler3Abi, generalAdapter1Abi } from "../../abis.js"; import { isRequirementApproval, isRequirementSignature, NativeAmountOnNonWNativeAssetError, NegativeInputError, NonPositiveInputError, + type PermitRequirementSignature, type VaultReallocation, + type VaultV2BlueReallocation, } from "../../types/index.js"; import { getGeneralAdapterRequirements } from "../requirements/index.js"; import * as getTokenRequirementActionsModule from "../signatures/getTokenRequirementActions.js"; @@ -208,6 +216,97 @@ describe("blueSupplyCollateralBorrow unit tests", () => { expect(tx.action.type).toBe("blueSupplyCollateralBorrow"); }); + test("behavior: shared-token permits fund collateral and V2 penalty with one pull", async ({ + client, + }) => { + const { + bundler3: { bundler3, generalAdapter1 }, + } = getChainAddresses(mainnet.id); + const sharedTokenMarket = new MarketParams({ + ...WethUsdsBlue, + loanToken: WethUsdsBlue.collateralToken, + }); + const reallocations: readonly VaultV2BlueReallocation[] = [ + { + vault: WethUsdsBlue.oracle, + from: { type: "idle" }, + to: { adapter: WethUsdsBlue.collateralToken }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + ]; + const combinedAmount = 105n; + const signature = `0x${"11".repeat(64)}1b` as Hex; + const requirementSignatures = [ + makePermit({ + owner: client.account.address, + asset: sharedTokenMarket.loanToken, + amount: combinedAmount, + }), + { + args: { + owner: client.account.address, + asset: sharedTokenMarket.loanToken, + amount: combinedAmount, + nonce: 0n, + deadline: 1_900_000_000n, + expiration: 1_900_000_000n, + signature, + }, + action: { + type: "permit2", + args: { + spender: generalAdapter1, + amount: combinedAmount, + deadline: 1_900_000_000n, + expiration: 1_900_000_000n, + }, + }, + }, + ] satisfies readonly PermitRequirementSignature[]; + + for (const requirementSignature of requirementSignatures) { + const tx = blueSupplyCollateralBorrow({ + market: { chainId: mainnet.id, marketParams: sharedTokenMarket }, + args: { + amount: 100n, + borrowAmount: 1n, + onBehalf: client.account.address, + receiver: client.account.address, + minSharePrice: 0n, + requirementSignature, + reallocations, + }, + }); + + expect(tx.action.args.reallocationPenaltyAssets).toBe(5n); + const bundle = decodeFunctionData({ abi: bundler3Abi, data: tx.data }); + const calls = bundle.args[0] ?? []; + expect(calls).toHaveLength(7); + expect( + decodeFunctionData({ + abi: generalAdapter1Abi, + data: calls[1]!.data, + }), + ).toMatchObject({ + functionName: + requirementSignature.action.type === "permit2" + ? "permit2TransferFrom" + : "erc20TransferFrom", + args: [sharedTokenMarket.loanToken, generalAdapter1, combinedAmount], + }); + expect( + decodeFunctionData({ + abi: generalAdapter1Abi, + data: calls[3]!.data, + }), + ).toMatchObject({ + functionName: "erc20Transfer", + args: [sharedTokenMarket.loanToken, bundler3, 5n], + }); + } + }); + test("should throw NegativeInputError when amount is negative", async ({ client, }) => { diff --git a/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.ts b/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.ts index 276a5e304..cf1d4f44e 100644 --- a/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.ts +++ b/packages/morpho-sdk/src/actions/blue/supplyCollateralBorrow.ts @@ -1,10 +1,11 @@ import type { MarketParams } from "@morpho-org/blue-sdk"; import { deepFreeze } from "@morpho-org/morpho-ts"; -import type { Address } from "viem"; +import { type Address, isAddressEqual } from "viem"; import { type Action, BundlerAction } from "../../bundler/index.js"; import { addTransactionMetadata } from "../../helpers/index.js"; import { type AuthorizationRequirementSignature, + type BlueReallocationPlan, type BlueSupplyCollateralBorrowAction, type DepositAmountArgs, type Metadata, @@ -12,11 +13,10 @@ import { NonPositiveInputError, type PermitRequirementSignature, type Transaction, - type VaultReallocation, } from "../../types/index.js"; import { getBlueAuthorizationAction } from "../signatures/getBlueAuthorizationAction.js"; import { buildAssetFundingActions } from "./buildAssetFundingActions.js"; -import { buildReallocationActions } from "./buildReallocationActions.js"; +import { buildBlueReallocationActions } from "./buildReallocationActions.js"; /** Parameters for {@link blueSupplyCollateralBorrow}. */ export interface BlueSupplyCollateralBorrowParams { @@ -35,8 +35,8 @@ export interface BlueSupplyCollateralBorrowParams { minSharePrice: bigint; /** Optional pre-signed permit/permit2 approval for the collateral transfer. */ requirementSignature?: PermitRequirementSignature; - /** Vault reallocations to execute before borrowing (computed by entity). */ - reallocations?: readonly VaultReallocation[]; + /** Homogeneous Vault V1 or Vault V2 reallocations to execute before borrowing. */ + reallocations?: BlueReallocationPlan; /** * Optional signed Morpho authorization. When provided, a `setAuthorizationWithSig` call is * prepended to the bundle so GeneralAdapter1 is authorized in-bundle instead of via a @@ -50,9 +50,13 @@ export interface BlueSupplyCollateralBorrowParams { /** * Prepares an atomic supply-collateral-and-borrow transaction for a Morpho Blue market. * - * Routed through bundler3: collateral transfer → `morphoSupplyCollateral` → optional - * `reallocateTo` calls → `morphoBorrow`. When `nativeAmount > 0`, native ETH is wrapped via - * `GeneralAdapter1.wrapNative()` before the supply leg. + * Routed through bundler3: collateral funding → `morphoSupplyCollateral` → optional Public + * Allocator calls → `morphoBorrow`. Each plan contains either V1 or V2 entries, + * never both. When `nativeAmount > 0`, native ETH is wrapped via + * `GeneralAdapter1.wrapNative()` before the supply leg. V1 fees add to + * `tx.value`; V2 penalties are paid in the target loan token and donated to the vaults. When the + * collateral and loan tokens match, one combined pull funds both collateral and penalties through + * `GeneralAdapter1`. * * Prerequisite: `GeneralAdapter1` must be authorized on Morpho to borrow on behalf of the user. * Use `getRequirements()` on the entity to check and obtain the authorization transaction. @@ -69,35 +73,40 @@ export interface BlueSupplyCollateralBorrowParams { * @param params.args.receiver - Address that receives the borrowed assets. * @param params.args.minSharePrice - Minimum borrow share price (in ray). Slippage protection. * @param params.args.requirementSignature - Optional pre-signed permit/permit2 approval for the - * collateral transfer. + * collateral funding. When collateral and loan tokens match, its amount includes V2 penalties. * @param params.args.nativeAmount - Optional amount of native token to wrap into wNative for the * collateral supply. Requires the collateral token to be the chain's wNative. - * @param params.args.reallocations - Optional vault reallocations to execute between the supply - * and borrow legs, computed by the entity layer. + * @param params.args.reallocations - Optional homogeneous Vault V1 or Vault V2 reallocations to + * execute between the supply and borrow legs. * @param params.args.authorizationSignature - Optional signed Morpho authorization; when present, * a `setAuthorizationWithSig` call is prepended to the bundle. * @param params.metadata - Optional analytics metadata attached to the bundle. * @returns A deep-frozen `Transaction` with `to`, `value`, * `data`, and the typed `action` discriminator the simulation layer consumes. - * @throws {NegativeInputError} when `amount`, `nativeAmount`, `minSharePrice`, or any reallocation - * fee is negative. + * @throws {NegativeInputError} when `amount`, `nativeAmount`, `minSharePrice`, a V1 fee, or a V2 + * penalty is negative. * @throws {NonPositiveInputError} when `borrowAmount <= 0n`, both collateral amounts resolve to * zero, or any reallocation withdrawal amount is non-positive. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. * @throws {ChainWNativeMissingError} when `nativeAmount > 0n` but the chain has no configured wNative. * @throws {NativeAmountOnNonWNativeAssetError} when `nativeAmount > 0n` but the collateral * token is not the chain's wNative. * @throws {DepositAssetMismatchError} from `getTokenRequirementActions` when `requirementSignature` * is provided and the signed asset differs from `marketParams.collateralToken`. * @throws {DepositAmountMismatchError} from `getTokenRequirementActions` when `requirementSignature` - * is provided and the signed amount differs from `args.amount`. + * is provided and the signed amount differs from the total ERC-20 funding amount. * @throws {Permit2ExpirationMissingError} from `getTokenRequirementActions` when a Permit2 requirement * signature is missing its expiration. - * @throws {EmptyReallocationWithdrawalsError} from `buildReallocationActions` when any - * `reallocation.withdrawals` is empty. - * @throws {ReallocationWithdrawalOnTargetMarketError} from `buildReallocationActions` when any - * reallocation withdrawal references the target market. - * @throws {UnsortedReallocationWithdrawalsError} from `buildReallocationActions` when - * reallocation withdrawals are not strictly sorted by market id. + * @throws {EmptyReallocationWithdrawalsError} when any `reallocation.withdrawals` is empty. + * @throws {ReallocationWithdrawalOnTargetMarketError} when any reallocation withdrawal references + * the target market. + * @throws {UnsortedReallocationWithdrawalsError} when reallocation withdrawals are not strictly + * sorted by market id. * @example * ```ts * import { blueSupplyCollateralBorrow } from "@morpho-org/morpho-sdk"; @@ -154,6 +163,21 @@ export const blueSupplyCollateralBorrow = ({ throw new NonPositiveInputError("totalCollateral", totalCollateral); } + const usesSharedFundingToken = isAddressEqual( + marketParams.collateralToken, + marketParams.loanToken, + ); + const reallocationResult = buildBlueReallocationActions({ + chainId, + reallocations, + targetMarketParams: marketParams, + penaltyFundingSource: usesSharedFundingToken + ? "generalAdapter1" + : "initiator", + }); + const erc20FundingAmount = + amount + (usesSharedFundingToken ? reallocationResult.penaltyAssets : 0n); + const actions: Action[] = []; if (authorizationSignature) { @@ -164,7 +188,7 @@ export const blueSupplyCollateralBorrow = ({ ...buildAssetFundingActions({ chainId, asset: marketParams.collateralToken, - erc20Amount: amount, + erc20Amount: erc20FundingAmount, nativeAmount: nativeAmount ?? 0n, requirementSignature, }), @@ -174,14 +198,7 @@ export const blueSupplyCollateralBorrow = ({ type: "morphoSupplyCollateral", args: [marketParams, totalCollateral, onBehalf, [], false], }); - - let reallocationFee = 0n; - - if (reallocations && reallocations.length > 0) { - const result = buildReallocationActions(reallocations, marketParams); - actions.push(...result.actions); - reallocationFee = result.fee; - } + actions.push(...reallocationResult.actions); actions.push({ type: "morphoBorrow", @@ -206,7 +223,8 @@ export const blueSupplyCollateralBorrow = ({ onBehalf, receiver, nativeAmount, - reallocationFee, + reallocationFee: reallocationResult.fee, + reallocationPenaltyAssets: reallocationResult.penaltyAssets, }, }, }); diff --git a/packages/morpho-sdk/src/actions/blue/vaultV2Reallocations.integration.test.ts b/packages/morpho-sdk/src/actions/blue/vaultV2Reallocations.integration.test.ts new file mode 100644 index 000000000..7ed5b82d6 --- /dev/null +++ b/packages/morpho-sdk/src/actions/blue/vaultV2Reallocations.integration.test.ts @@ -0,0 +1,570 @@ +import { + getChainAddresses, + MarketParams, + MathLib, + marketParamsAbi, +} from "@morpho-org/blue-sdk"; +import { + blueAbi, + fetchAccrualVaultV2, + readContractRestructured, + vaultV2Abi, + vaultV2BluePublicAllocatorAbi, +} from "@morpho-org/blue-sdk-viem"; +import type { AnvilTestClient } from "@morpho-org/test"; +import { createViemTest } from "@morpho-org/test/vitest"; +import { + encodeAbiParameters, + encodeFunctionData, + erc20Abi, + keccak256, + maxUint128, + parseUnits, +} from "viem"; +import { base } from "viem/chains"; +import { assert, describe, expect } from "vitest"; +import { supplyCollateral } from "../../../test/helpers/blue.js"; +import { + deployMorphoMarketV1AdapterV2, + deployVaultV2, + submitAndAcceptVaultV2Call, +} from "../../../test/helpers/vaultV2.js"; +import { + isRequirementApproval, + isRequirementBlueAuthorization, + morphoViemExtension, +} from "../../index.js"; +import type { VaultV2BlueReallocation } from "../../types/index.js"; + +const test = createViemTest(base, { + forkUrl: process.env.BASE_RPC_URL, + forkBlockNumber: 50_063_965n, // BluePublicAllocator deployment block. + stepsTracing: false, +}); + +const sourceMarket = new MarketParams({ + loanToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + collateralToken: "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", + oracle: "0x663BECd10daE6C4A3Dcd89F1d76c1174199639B9", + irm: "0x46415998764C29aB2a25CbeA6254146D50D22687", + lltv: 860_000_000_000_000_000n, +}); + +const targetMarket = new MarketParams({ + loanToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + collateralToken: "0x4200000000000000000000000000000000000006", + oracle: "0xFEa2D58cEfCb9fcb597723c6bAE66fFE4193aFE4", + irm: "0x46415998764C29aB2a25CbeA6254146D50D22687", + lltv: 860_000_000_000_000_000n, +}); + +describe("Blue actions with Vault V2 reallocations", () => { + test("executes market and idle reallocations through live Base contracts", async ({ + client, + }) => { + const anvilClient = client as AnvilTestClient; + const { + morpho, + bundler3, + vaultV2BluePublicAllocator: allocator, + } = getChainAddresses(base.id); + assert(allocator != null); + const sourceAssets = parseUnits("20", 6); + const idleAssets = parseUnits("10", 6); + const sourceDeposit = parseUnits("100", 6); + const initialIdleAssets = parseUnits("20", 6); + const penalty = parseUnits("0.01", 18); + const borrowAmount = parseUnits("1", 6); + const collateralAmount = parseUnits("1", 18); + const totalPenaltyAssets = + MathLib.wMulUp(sourceAssets, penalty) + + MathLib.wMulUp(idleAssets, penalty); + + for (const marketParams of [sourceMarket, targetMarket]) { + const marketState = await readContractRestructured(client, { + address: morpho, + abi: blueAbi, + functionName: "market", + args: [marketParams.id], + }); + if (marketState.lastUpdate === 0n) { + await client.writeContract({ + address: morpho, + abi: blueAbi, + functionName: "createMarket", + args: [marketParams], + }); + } + } + + const vault = await deployVaultV2(anvilClient, targetMarket.loanToken); + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "setIsAllocator", + args: [client.account.address, true], + }), + }); + + const sourceAdapter = await deployMorphoMarketV1AdapterV2( + anvilClient, + vault, + ); + const targetAdapter = sourceAdapter; + + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "addAdapter", + args: [sourceAdapter], + }), + }); + + const idsData = new Set( + [sourceMarket, targetMarket].flatMap((marketParams) => [ + encodeAbiParameters( + [{ type: "string" }, { type: "address" }], + ["this", sourceAdapter], + ), + encodeAbiParameters( + [{ type: "string" }, { type: "address" }], + ["collateralToken", marketParams.collateralToken], + ), + encodeAbiParameters( + [{ type: "string" }, { type: "address" }, marketParamsAbi], + ["this/marketParams", sourceAdapter, marketParams], + ), + ]), + ); + + for (const idData of idsData) { + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "increaseAbsoluteCap", + args: [idData, maxUint128], + }), + }); + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "increaseRelativeCap", + args: [idData, MathLib.WAD], + }), + }); + } + + await client.writeContract({ + address: vault, + abi: vaultV2Abi, + functionName: "setLiquidityAdapterAndData", + args: [ + sourceAdapter, + encodeAbiParameters([marketParamsAbi], [sourceMarket]), + ], + }); + await client.deal({ + account: client.account.address, + erc20: targetMarket.loanToken, + amount: sourceDeposit, + }); + await client.approve({ + address: targetMarket.loanToken, + args: [vault, sourceDeposit], + }); + await client.writeContract({ + address: vault, + abi: vaultV2Abi, + functionName: "deposit", + args: [sourceDeposit, client.account.address], + }); + await client.deal({ + account: vault, + erc20: targetMarket.loanToken, + amount: initialIdleAssets, + }); + + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "setIsAllocator", + args: [allocator, true], + }), + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setIsActiveAdapter", + args: [vault, sourceAdapter, true], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setAbsoluteCap", + args: [vault, targetAdapter, targetMarket, maxUint128], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setCanPullFromMarket", + args: [vault, sourceAdapter, sourceMarket, true], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setCanPullFromIdle", + args: [vault, true], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setPenalty", + args: [vault, penalty], + }); + + await supplyCollateral({ + client: anvilClient, + chainId: base.id, + market: targetMarket, + collateralAmount, + }); + await client.deal({ + account: client.account.address, + erc20: targetMarket.loanToken, + amount: totalPenaltyAssets, + }); + + const morphoClient = client.extend(morphoViemExtension()).morpho; + const market = morphoClient.blue(targetMarket, base.id); + const block = await client.getBlock(); + const reallocationData = await market.getVaultV2BlueReallocationData({ + vaultAddresses: [vault], + block, + }); + expect( + reallocationData.getAdapter(vault, targetAdapter).marketIds, + ).not.toContain(targetMarket.id); + const discovery = reallocationData.computeVaultV2BlueReallocations( + targetMarket.id, + { timestamp: block.timestamp, maxPenalty: penalty }, + ); + expect(discovery.reallocations.length).toBeGreaterThan(0); + expect(discovery.data.getAdapter(vault, targetAdapter).marketIds).toContain( + targetMarket.id, + ); + + const reallocations: readonly VaultV2BlueReallocation[] = [ + { + vault, + from: { + type: "market", + adapter: sourceAdapter, + marketParams: sourceMarket, + }, + to: { adapter: targetAdapter }, + assets: sourceAssets, + penalty, + }, + { + vault, + from: { type: "idle" }, + to: { adapter: targetAdapter }, + assets: idleAssets, + penalty, + }, + ]; + + const positionData = await market.getPositionData(client.account.address); + const borrow = market.borrow({ + userAddress: client.account.address, + amount: borrowAmount, + positionData, + reallocations, + }); + const requirements = await borrow.getRequirements(); + const approval = requirements.find(isRequirementApproval); + const authorization = requirements.find(isRequirementBlueAuthorization); + assert(approval != null); + assert(authorization != null); + await client.sendTransaction(approval); + await client.sendTransaction(authorization); + + const [sourcePositionBefore, targetPositionBefore, vaultBalanceBefore] = + await Promise.all([ + readContractRestructured(client, { + address: morpho, + abi: blueAbi, + functionName: "position", + args: [sourceMarket.id, sourceAdapter], + }), + readContractRestructured(client, { + address: morpho, + abi: blueAbi, + functionName: "position", + args: [targetMarket.id, targetAdapter], + }), + client.readContract({ + address: targetMarket.loanToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [vault], + }), + ]); + + await client.sendTransaction(borrow.buildTx()); + + const [ + sourcePositionAfter, + targetPositionAfter, + vaultBalanceAfter, + bundlerBalanceAfter, + allocatorAllowanceAfter, + ] = await Promise.all([ + readContractRestructured(client, { + address: morpho, + abi: blueAbi, + functionName: "position", + args: [sourceMarket.id, sourceAdapter], + }), + readContractRestructured(client, { + address: morpho, + abi: blueAbi, + functionName: "position", + args: [targetMarket.id, targetAdapter], + }), + client.readContract({ + address: targetMarket.loanToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [vault], + }), + client.readContract({ + address: targetMarket.loanToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [bundler3.bundler3], + }), + client.readContract({ + address: targetMarket.loanToken, + abi: erc20Abi, + functionName: "allowance", + args: [bundler3.bundler3, allocator], + }), + ]); + + expect(sourcePositionAfter.supplyShares).toBeLessThan( + sourcePositionBefore.supplyShares, + ); + expect(targetPositionBefore.supplyShares).toBe(0n); + expect(targetPositionAfter.supplyShares).toBeGreaterThan(0n); + expect(vaultBalanceBefore).toBe(initialIdleAssets); + expect(vaultBalanceAfter).toBe( + vaultBalanceBefore + totalPenaltyAssets - idleAssets, + ); + expect(bundlerBalanceAfter).toBe(0n); + expect(allocatorAllowanceAfter).toBe(0n); + }); + + test("executes the simulated zero-elapsed relative-cap maximum", async ({ + client, + }) => { + const anvilClient = client as AnvilTestClient; + const { morpho, vaultV2BluePublicAllocator: allocator } = getChainAddresses( + base.id, + ); + assert(allocator != null); + const depositAssets = parseUnits("100", 6); + const seedAssets = parseUnits("1", 6); + const postLossIdleAssets = parseUnits("89", 6); + const relativeCap = MathLib.WAD / 2n; + + const marketState = await readContractRestructured(client, { + address: morpho, + abi: blueAbi, + functionName: "market", + args: [targetMarket.id], + }); + if (marketState.lastUpdate === 0n) { + await client.writeContract({ + address: morpho, + abi: blueAbi, + functionName: "createMarket", + args: [targetMarket], + }); + } + + const vault = await deployVaultV2(anvilClient, targetMarket.loanToken); + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "setIsAllocator", + args: [client.account.address, true], + }), + }); + const targetAdapter = await deployMorphoMarketV1AdapterV2( + anvilClient, + vault, + ); + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "addAdapter", + args: [targetAdapter], + }), + }); + + const targetIdData = [ + encodeAbiParameters( + [{ type: "string" }, { type: "address" }], + ["this", targetAdapter], + ), + encodeAbiParameters( + [{ type: "string" }, { type: "address" }], + ["collateralToken", targetMarket.collateralToken], + ), + encodeAbiParameters( + [{ type: "string" }, { type: "address" }, marketParamsAbi], + ["this/marketParams", targetAdapter, targetMarket], + ), + ] as const; + for (const idData of targetIdData) { + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "increaseAbsoluteCap", + args: [idData, maxUint128], + }), + }); + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "increaseRelativeCap", + args: [idData, relativeCap], + }), + }); + } + + await submitAndAcceptVaultV2Call(anvilClient, { + vault, + data: encodeFunctionData({ + abi: vaultV2Abi, + functionName: "setIsAllocator", + args: [allocator, true], + }), + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setIsActiveAdapter", + args: [vault, targetAdapter, true], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setAbsoluteCap", + args: [vault, targetAdapter, targetMarket, maxUint128], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "setCanPullFromIdle", + args: [vault, true], + }); + + await client.deal({ + account: client.account.address, + erc20: targetMarket.loanToken, + amount: depositAssets, + }); + await client.approve({ + address: targetMarket.loanToken, + args: [vault, depositAssets], + }); + await client.writeContract({ + address: vault, + abi: vaultV2Abi, + functionName: "deposit", + args: [depositAssets, client.account.address], + }); + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "allocateFromIdle", + args: [vault, targetAdapter, targetMarket, seedAssets, 0n], + }); + + // Change the token balance without mining so the snapshot timestamp still + // equals lastUpdate while real vault assets are below stored _totalAssets. + await client.deal({ + account: vault, + erc20: targetMarket.loanToken, + amount: postLossIdleAssets, + }); + + const block = await client.getBlock(); + const market = client + .extend(morphoViemExtension()) + .morpho.blue(targetMarket, base.id); + const reallocationData = await market.getVaultV2BlueReallocationData({ + vaultAddresses: [vault], + block, + }); + const vaultData = reallocationData.getVault(vault); + const targetMarketParamsId = keccak256(targetIdData[2]); + const targetAllocation = reallocationData.getAllocation( + vault, + targetMarketParamsId, + ); + const realTotalAssets = vaultData.accrualAdapters.reduce( + (assets, adapter) => assets + adapter.realAssets(block.timestamp), + vaultData.assetBalance, + ); + const expectedMaximum = + MathLib.wMulDown(realTotalAssets, relativeCap) - + targetAllocation.allocation; + expect(block.timestamp).toBe(vaultData.lastUpdate); + expect(realTotalAssets).toBeLessThan(vaultData._totalAssets); + const result = reallocationData.computeVaultV2BlueReallocations( + targetMarket.id, + { timestamp: block.timestamp }, + ); + expect(result.reallocations).toHaveLength(1); + expect(result.reallocations[0]?.assets).toBe(expectedMaximum); + + await client.writeContract({ + address: allocator, + abi: vaultV2BluePublicAllocatorAbi, + functionName: "allocateFromIdle", + args: [ + vault, + targetAdapter, + targetMarket, + result.reallocations[0]!.assets, + 0n, + ], + }); + + const [allocationAfter, vaultAfter] = await Promise.all([ + client.readContract({ + address: vault, + abi: vaultV2Abi, + functionName: "allocation", + args: [targetMarketParamsId], + }), + fetchAccrualVaultV2(vault, client), + ]); + expect(allocationAfter).toBeGreaterThan(targetAllocation.allocation); + expect(allocationAfter).toBeLessThanOrEqual( + MathLib.wMulDown(vaultAfter._totalAssets, relativeCap), + ); + }); +}); diff --git a/packages/morpho-sdk/src/actions/blue/withdraw.bluePublicAllocator.test.ts b/packages/morpho-sdk/src/actions/blue/withdraw.bluePublicAllocator.test.ts new file mode 100644 index 000000000..dad2f0352 --- /dev/null +++ b/packages/morpho-sdk/src/actions/blue/withdraw.bluePublicAllocator.test.ts @@ -0,0 +1,106 @@ +import { getChainAddresses } from "@morpho-org/blue-sdk"; +import { type Address, decodeFunctionData, erc20Abi } from "viem"; +import { mainnet } from "viem/chains"; +import { describe, expect, test } from "vitest"; +import { + CbbtcUsdcBlue, + WbtcUsdcSourceMarket, +} from "../../../test/fixtures/blue.js"; +import { + bundler3Abi, + generalAdapter1Abi, + vaultV2BluePublicAllocatorAbi, +} from "../../abis.js"; +import type { VaultV2BlueReallocation } from "../../types/index.js"; +import { blueWithdraw } from "./withdraw.js"; + +const allocator = getChainAddresses(mainnet.id).vaultV2BluePublicAllocator!; +const vault: Address = "0x0000000000000000000000000000000000000012"; +const sourceAdapter: Address = "0x0000000000000000000000000000000000000013"; +const targetAdapter: Address = "0x0000000000000000000000000000000000000014"; +const receiver: Address = "0x0000000000000000000000000000000000000015"; + +describe("blueWithdraw Blue Public Allocator", () => { + test("market and idle reallocations fund penalties before morphoWithdraw", () => { + const { + bundler3: { bundler3 }, + } = getChainAddresses(mainnet.id); + const reallocations: readonly VaultV2BlueReallocation[] = [ + { + vault, + from: { + type: "market", + adapter: sourceAdapter, + marketParams: WbtcUsdcSourceMarket, + }, + to: { adapter: targetAdapter }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + { + vault, + from: { type: "idle" }, + to: { adapter: targetAdapter }, + assets: 6n, + penalty: 500_000_000_000_000_000n, + }, + ]; + + const tx = blueWithdraw({ + market: { chainId: mainnet.id, marketParams: CbbtcUsdcBlue }, + args: { + assets: 100n, + shares: 0n, + receiver, + minSharePrice: 0n, + reallocations, + }, + metadata: { origin: "a1b2c3d4" }, + }); + + expect(tx.value).toBe(0n); + expect(tx.action.args.reallocationFee).toBe(0n); + expect(tx.action.args.reallocationPenaltyAssets).toBe(8n); + expect(tx.data).toContain("a1b2c3d4"); + + const bundle = decodeFunctionData({ abi: bundler3Abi, data: tx.data }); + const calls = bundle.args[0] ?? []; + expect(calls).toHaveLength(6); + expect( + decodeFunctionData({ abi: generalAdapter1Abi, data: calls[0]!.data }), + ).toMatchObject({ + functionName: "erc20TransferFrom", + args: [CbbtcUsdcBlue.loanToken, bundler3, 8n], + }); + expect( + decodeFunctionData({ abi: erc20Abi, data: calls[1]!.data }), + ).toMatchObject({ + functionName: "approve", + args: [allocator, 5n], + }); + expect( + decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: calls[2]!.data, + }).functionName, + ).toBe("reallocate"); + expect( + decodeFunctionData({ abi: erc20Abi, data: calls[3]!.data }), + ).toMatchObject({ + functionName: "approve", + args: [allocator, 3n], + }); + expect( + decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: calls[4]!.data, + }).functionName, + ).toBe("allocateFromIdle"); + expect( + decodeFunctionData({ + abi: generalAdapter1Abi, + data: calls[5]!.data, + }).functionName, + ).toBe("morphoWithdraw"); + }); +}); diff --git a/packages/morpho-sdk/src/actions/blue/withdraw.ts b/packages/morpho-sdk/src/actions/blue/withdraw.ts index 5245da888..37e112b45 100644 --- a/packages/morpho-sdk/src/actions/blue/withdraw.ts +++ b/packages/morpho-sdk/src/actions/blue/withdraw.ts @@ -5,16 +5,16 @@ import { type Action, BundlerAction } from "../../bundler/index.js"; import { addTransactionMetadata } from "../../helpers/index.js"; import { type AuthorizationRequirementSignature, + type BlueReallocationPlan, type BlueWithdrawAction, type Metadata, MutuallyExclusiveWithdrawAmountsError, NegativeInputError, NonPositiveInputError, type Transaction, - type VaultReallocation, } from "../../types/index.js"; import { getBlueAuthorizationAction } from "../signatures/getBlueAuthorizationAction.js"; -import { buildReallocationActions } from "./buildReallocationActions.js"; +import { buildBlueReallocationActions } from "./buildReallocationActions.js"; /** Parameters for {@link blueWithdraw}. */ export interface BlueWithdrawParams { @@ -32,11 +32,11 @@ export interface BlueWithdrawParams { /** Minimum withdraw share price (in ray). Slippage protection. */ minSharePrice: bigint; /** - * Vault reallocations to execute before withdrawing. Compute via - * `MorphoBlue.getReallocations({ operation: "withdraw", amount })` or directly via - * `computeReallocations({ operation: "withdraw", amount, ... })`. + * Homogeneous Vault V1 or Vault V2 reallocations to execute before withdrawing. V1 entries can be + * computed via `MorphoBlue.getVaultV1Reallocations({ operation: "withdraw", amount })` or directly + * via `computeVaultV1Reallocations({ operation: "withdraw", amount, ... })`. */ - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; /** * Optional signed Morpho authorization. When provided, a `setAuthorizationWithSig` call is * prepended to the bundle so GeneralAdapter1 is authorized in-bundle instead of via a @@ -56,10 +56,11 @@ export interface BlueWithdrawParams { * - **By shares** (`assets = 0, shares > 0`): burns an exact share count (typical for a full * supplier position close; immune to interest accrual between tx construction and execution). * - * When `reallocations` are provided, `reallocateTo` actions are prepended to the bundle, moving - * liquidity from other markets into this one via the PublicAllocator before withdrawing. - * Reallocation fees accumulate in `tx.value`. The on-chain `morphoWithdraw` sends the assets - * computed on-chain directly to `receiver`; no skim is required. + * A `reallocations` plan contains either V1 entries or V2 market/idle entries, + * never both. The calls run before the withdraw. V1 + * fees accumulate in `tx.value`; V2 penalties are paid in the target loan + * token and donated to the vaults. The on-chain `morphoWithdraw` sends the + * assets computed on-chain directly to `receiver`; no skim is required. * * The withdraw is performed on behalf of the transaction initiator (signer) — there is no * separate `onBehalf` field; mirror `blueBorrow`. The entity layer keeps `receiver` aligned @@ -73,17 +74,23 @@ export interface BlueWithdrawParams { * @param params.args.receiver - Address that receives the withdrawn assets. * @param params.args.minSharePrice - Minimum acceptable withdraw share price (in ray). Slippage * protection. - * @param params.args.reallocations - Optional vault reallocations to execute before withdrawing, - * computed by the entity layer. + * @param params.args.reallocations - Optional homogeneous Vault V1 or Vault V2 reallocations to + * execute before withdrawing. * @param params.args.authorizationSignature - Optional signed Morpho authorization; when present, * a `setAuthorizationWithSig` call is prepended to the bundle. * @param params.metadata - Optional analytics metadata attached to the bundle. * @returns A deep-frozen `Transaction` with `to`, `value`, `data`, and * the typed `action` discriminator the simulation layer consumes. - * @throws {NegativeInputError} when `assets`, `shares`, `minSharePrice`, or any reallocation fee - * is negative. + * @throws {NegativeInputError} when `assets`, `shares`, `minSharePrice`, a V1 fee, or a V2 + * penalty is negative. * @throws {NonPositiveInputError} when both `assets` and `shares` are zero or any reallocation * withdrawal amount is non-positive. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. * @throws {MutuallyExclusiveWithdrawAmountsError} when both `assets` and `shares` are non-zero. * @throws {EmptyReallocationWithdrawalsError} when any reallocation has no withdrawals. * @throws {ReallocationWithdrawalOnTargetMarketError} when a reallocation withdrawal references @@ -140,27 +147,28 @@ export const blueWithdraw = ({ } const actions: Action[] = []; - let reallocationFee = 0n; if (authorizationSignature) { actions.push(getBlueAuthorizationAction(chainId, authorizationSignature)); } - if (reallocations && reallocations.length > 0) { - const result = buildReallocationActions(reallocations, marketParams); - actions.push(...result.actions); - reallocationFee = result.fee; - } + const { + actions: reallocationActions, + fee: reallocationFee, + penaltyAssets: reallocationPenaltyAssets, + } = buildBlueReallocationActions({ + chainId, + reallocations, + targetMarketParams: marketParams, + }); + actions.push(...reallocationActions); actions.push({ type: "morphoWithdraw", args: [marketParams, assets, shares, minSharePrice, receiver, false], }); - let tx = { - ...BundlerAction.encodeBundle(chainId, actions), - value: reallocationFee, - }; + let tx = BundlerAction.encodeBundle(chainId, actions); if (metadata) { tx = addTransactionMetadata(tx, metadata); @@ -177,6 +185,7 @@ export const blueWithdraw = ({ receiver, minSharePrice, reallocationFee, + reallocationPenaltyAssets, }, }, }); diff --git a/packages/morpho-sdk/src/bundler/actions.test.ts b/packages/morpho-sdk/src/bundler/actions.test.ts index ad10d7f0c..48d63a245 100644 --- a/packages/morpho-sdk/src/bundler/actions.test.ts +++ b/packages/morpho-sdk/src/bundler/actions.test.ts @@ -7,7 +7,8 @@ import { blueAbi, erc2612Abi, permit2Abi, - publicAllocatorAbi, + vaultV1PublicAllocatorAbi, + vaultV2BluePublicAllocatorAbi, } from "@morpho-org/blue-sdk-viem"; import fc from "fast-check"; import { @@ -15,6 +16,7 @@ import { bytesToHex, decodeFunctionData, encodeAbiParameters, + erc20Abi, type Hex, isAddressEqual, keccak256, @@ -37,6 +39,7 @@ describe("BundlerAction", () => { morpho, permit2, publicAllocator, + vaultV2BluePublicAllocator: allocator, bundler3: { bundler3, generalAdapter1 }, } = getChainAddresses(chainId); @@ -46,6 +49,8 @@ describe("BundlerAction", () => { const adapter = "0x0000000000000000000000000000000000000004"; const erc4626 = "0x0000000000000000000000000000000000000005"; const vault = "0x0000000000000000000000000000000000000006"; + const deallocateAdapter = "0x0000000000000000000000000000000000000012"; + const allocateAdapter = "0x0000000000000000000000000000000000000013"; const loanToken = "0x0000000000000000000000000000000000000007"; const collateralToken = "0x0000000000000000000000000000000000000008"; const oracle = "0x0000000000000000000000000000000000000009"; @@ -76,6 +81,10 @@ describe("BundlerAction", () => { .uint8Array({ minLength: 20, maxLength: 20 }) .map((bytes) => bytesToHex(bytes) as Address); const amountArbitrary = fc.bigInt({ min: 0n, max: 10n ** 24n }); + const penaltyArbitrary = fc.bigInt({ + min: 0n, + max: 1_000_000_000_000_000_000n, + }); const permitNumberArbitrary = fc.integer({ min: 0, max: 1_000_000 }); const skipRevertArbitrary = fc.boolean(); const marketArbitrary = fc.record({ @@ -342,6 +351,40 @@ describe("BundlerAction", () => { args, }) satisfies Action, ), + fc + .tuple( + addressArbitrary, + addressArbitrary, + marketArbitrary, + addressArbitrary, + marketArbitrary, + amountArbitrary, + penaltyArbitrary, + fc.constant(false), + ) + .map( + (args) => + ({ + type: "vaultV2BluePublicAllocatorReallocate", + args, + }) satisfies Action, + ), + fc + .tuple( + addressArbitrary, + addressArbitrary, + marketArbitrary, + amountArbitrary, + penaltyArbitrary, + fc.constant(false), + ) + .map( + (args) => + ({ + type: "vaultV2BluePublicAllocatorAllocateFromIdle", + args, + }) satisfies Action, + ), fc.tuple(amountArbitrary, addressArbitrary, skipRevertArbitrary).map( (args) => ({ @@ -570,6 +613,39 @@ describe("BundlerAction", () => { expect(calls[0]?.value).toBe(5n); }); + test("encodeBundle keeps Blue Public Allocator calls nonpayable", () => { + const tx = BundlerAction.encodeBundle(chainId, [ + { + type: "vaultV2BluePublicAllocatorReallocate", + args: [ + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1n, + 2n, + false, + ], + }, + { + type: "vaultV2BluePublicAllocatorAllocateFromIdle", + args: [vault, allocateAdapter, market, 3n, 4n, false], + }, + ]); + + expect(tx.value).toBe(0n); + + const decoded = decodeFunctionData({ abi: bundler3Abi, data: tx.data }); + expect(decoded.functionName).toBe("multicall"); + expect((decoded.args[0] ?? []).map((call) => call.value)).toEqual([ + 0n, + 0n, + 0n, + 0n, + ]); + }); + test("encodeBundle includes callback action values in transaction value", () => { const tx = BundlerAction.encodeBundle(chainId, [ { @@ -972,6 +1048,49 @@ describe("BundlerAction", () => { false, ), ], + [ + "vaultV2BluePublicAllocatorReallocate", + { + type: "vaultV2BluePublicAllocatorReallocate", + args: [ + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 20n, + 21n, + false, + ], + }, + BundlerAction.vaultV2BluePublicAllocatorReallocate( + chainId, + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 20n, + 21n, + false, + ), + ], + [ + "vaultV2BluePublicAllocatorAllocateFromIdle", + { + type: "vaultV2BluePublicAllocatorAllocateFromIdle", + args: [vault, allocateAdapter, market, 22n, 23n, false], + }, + BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + chainId, + vault, + allocateAdapter, + market, + 22n, + 23n, + false, + ), + ], [ "wrapNative", { @@ -1434,7 +1553,7 @@ describe("BundlerAction", () => { ), ); const decoded = decodeFunctionData({ - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, data: call.data, }); @@ -1445,6 +1564,181 @@ describe("BundlerAction", () => { expect(decoded.args).toEqual([vault, withdrawals, market]); }); + test("vaultV2BluePublicAllocatorReallocate", () => { + const penalty = 1_000_000_000_000_000n; + const [approval, call] = BundlerAction.vaultV2BluePublicAllocatorReallocate( + chainId, + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1_000_000n, + penalty, + ); + expect(approval).toBeDefined(); + expect(call).toBeDefined(); + expect( + decodeFunctionData({ abi: erc20Abi, data: approval!.data }), + ).toMatchObject({ + functionName: "approve", + args: [allocator, 1_000n], + }); + expect(approval).toMatchObject({ + to: market.loanToken, + value: 0n, + skipRevert: false, + }); + const decoded = decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: call!.data, + }); + + expect(call!.to).toBe(allocator); + expect(call!.value).toBe(0n); + expect(call!.skipRevert).toBe(false); + expect(decoded.functionName).toBe("reallocate"); + expect(decoded.args).toEqual([ + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1_000_000n, + penalty, + ]); + }); + + test("vaultV2BluePublicAllocatorReallocate with zero penalty", () => { + const call = onlyCall( + BundlerAction.vaultV2BluePublicAllocatorReallocate( + chainId, + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1n, + 0n, + true, + ), + ); + const decoded = decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: call.data, + }); + + expect(call.to).toBe(allocator); + expect(call.value).toBe(0n); + expect(call.skipRevert).toBe(true); + expect(decoded.functionName).toBe("reallocate"); + expect(decoded.args).toEqual([ + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1n, + 0n, + ]); + }); + + test("vaultV2BluePublicAllocatorReallocate rejects a skippable penalty approval", () => { + expect(() => + BundlerAction.vaultV2BluePublicAllocatorReallocate( + chainId, + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1_000_000n, + 1_000_000_000_000_000n, + true, + ), + ).toThrow(BundlerErrors.SkippableAllocatorPenalty); + }); + + test("vaultV2BluePublicAllocatorAllocateFromIdle", () => { + const penalty = 1_000_000_000_000_000n; + const [approval, call] = + BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + chainId, + vault, + allocateAdapter, + market, + 1_000_000n, + penalty, + ); + expect(approval).toBeDefined(); + expect(call).toBeDefined(); + expect( + decodeFunctionData({ abi: erc20Abi, data: approval!.data }), + ).toMatchObject({ + functionName: "approve", + args: [allocator, 1_000n], + }); + expect(approval).toMatchObject({ + to: market.loanToken, + value: 0n, + skipRevert: false, + }); + const decoded = decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: call!.data, + }); + + expect(call!.to).toBe(allocator); + expect(call!.value).toBe(0n); + expect(call!.skipRevert).toBe(false); + expect(decoded.functionName).toBe("allocateFromIdle"); + expect(decoded.args).toEqual([ + vault, + allocateAdapter, + market, + 1_000_000n, + penalty, + ]); + }); + + test("vaultV2BluePublicAllocatorAllocateFromIdle with zero penalty", () => { + const call = onlyCall( + BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + chainId, + vault, + allocateAdapter, + market, + 1n, + 0n, + true, + ), + ); + const decoded = decodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + data: call.data, + }); + + expect(call.to).toBe(allocator); + expect(call.value).toBe(0n); + expect(call.skipRevert).toBe(true); + expect(decoded.functionName).toBe("allocateFromIdle"); + expect(decoded.args).toEqual([vault, allocateAdapter, market, 1n, 0n]); + }); + + test("vaultV2BluePublicAllocatorAllocateFromIdle rejects a skippable penalty approval", () => { + expect(() => + BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + chainId, + vault, + allocateAdapter, + market, + 1_000_000n, + 1_000_000_000_000_000n, + true, + ), + ).toThrow(BundlerErrors.SkippableAllocatorPenalty); + }); + test("wrapNative", () => { const call = onlyCall( BundlerAction.wrapNative(chainId, 1n, recipient, true), @@ -1495,5 +1789,29 @@ describe("BundlerAction", () => { market, ), ).toThrow(BundlerErrors.UnexpectedAction); + + expect(() => + BundlerAction.vaultV2BluePublicAllocatorReallocate( + ChainId.FraxtalMainnet, + vault, + deallocateAdapter, + market, + allocateAdapter, + market, + 1n, + 0n, + ), + ).toThrow(BundlerErrors.UnexpectedAction); + + expect(() => + BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + ChainId.FraxtalMainnet, + vault, + allocateAdapter, + market, + 1n, + 0n, + ), + ).toThrow(BundlerErrors.UnexpectedAction); }); }); diff --git a/packages/morpho-sdk/src/bundler/actions.ts b/packages/morpho-sdk/src/bundler/actions.ts index 6d27997ac..253ade92c 100644 --- a/packages/morpho-sdk/src/bundler/actions.ts +++ b/packages/morpho-sdk/src/bundler/actions.ts @@ -1,17 +1,20 @@ import { getChainAddresses, type InputMarketParams, + VaultV2BluePublicAllocatorConfigUtils, } from "@morpho-org/blue-sdk"; import { blueAbi, erc2612Abi, permit2Abi, - publicAllocatorAbi, + vaultV1PublicAllocatorAbi, + vaultV2BluePublicAllocatorAbi, } from "@morpho-org/blue-sdk-viem"; import { type Address, encodeAbiParameters, encodeFunctionData, + erc20Abi, type Hex, isAddressEqual, keccak256, @@ -342,6 +345,18 @@ export namespace BundlerAction { case "reallocateTo": { return BundlerAction.publicAllocatorReallocateTo(chainId, ...args); } + case "vaultV2BluePublicAllocatorReallocate": { + return BundlerAction.vaultV2BluePublicAllocatorReallocate( + chainId, + ...args, + ); + } + case "vaultV2BluePublicAllocatorAllocateFromIdle": { + return BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + chainId, + ...args, + ); + } case "wrapNative": { return BundlerAction.wrapNative(chainId, ...args); } @@ -1421,16 +1436,16 @@ export namespace BundlerAction { supplyMarketParams: InputMarketParams, skipRevert = false, ): BundlerCall[] { - const { publicAllocator } = getChainAddresses(chainId); - if (publicAllocator == null) { + const { vaultV1PublicAllocator } = getChainAddresses(chainId); + if (vaultV1PublicAllocator == null) { throw new BundlerErrors.UnexpectedAction("reallocateTo", chainId); } return [ { - to: publicAllocator, + to: vaultV1PublicAllocator, data: encodeFunctionData({ - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "reallocateTo", args: [vault, withdrawals, supplyMarketParams], }), @@ -1441,6 +1456,245 @@ export namespace BundlerAction { ]; } + /** + * Encodes a Vault V2 Blue Public Allocator market-to-market reallocation. + * + * @remarks Bundler3 must already hold the computed penalty assets. The + * high-level Blue builders add the corresponding GeneralAdapter1 transfer. + * + * @param chainId - Chain whose canonical Blue Public Allocator is called. + * @param vault - Vault whose liquidity is reallocated. + * @param deallocateAdapter - Vault V2 adapter supplying the source market. + * @param deallocateMarket - Source Morpho Blue market parameters. + * @param allocateAdapter - Vault V2 adapter supplying the target market. + * @param allocateMarket - Target Morpho Blue market parameters. + * @param assets - Assets to reallocate, bounded by `uint128` by the high-level action. + * @param penalty - Vault-configured proportional penalty, scaled by WAD. + * @param skipRevert - Whether Bundler3 should tolerate a revert. + * @returns An exact token approval when needed, followed by the allocator call. + * @throws {BundlerErrors.UnexpectedAction} when the chain has no Blue Public Allocator deployment. + * @throws {BundlerErrors.SkippableAllocatorPenalty} when `skipRevert` is true and a token approval is required. + * @example + * ```ts + * import { ChainId, type InputMarketParams } from "@morpho-org/blue-sdk"; + * import { + * BundlerAction, + * type BundlerCall, + * } from "@morpho-org/morpho-sdk/bundler"; + * import type { Address } from "viem"; + * + * const keyrockUsdcVault = + * "0x04422053aDDbc9bB2759b248B574e3FCA76Bc145" satisfies Address; + * const sourceAdapterFixture = + * "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" satisfies Address; + * const targetAdapterFixture = + * "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" satisfies Address; + * const usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" satisfies Address; + * const weth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" satisfies Address; + * const wbtc = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" satisfies Address; + * const ethUsdOracle = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" satisfies Address; + * const adaptiveCurveIrm = "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC" satisfies Address; + * const sourceMarket = { + * loanToken: usdc, + * collateralToken: weth, + * oracle: ethUsdOracle, + * irm: adaptiveCurveIrm, + * lltv: 860_000_000_000_000_000n, + * } satisfies InputMarketParams; + * const targetMarket = { + * ...sourceMarket, + * collateralToken: wbtc, + * } satisfies InputMarketParams; + * + * const calls: BundlerCall[] = BundlerAction.vaultV2BluePublicAllocatorReallocate( + * ChainId.EthMainnet, + * keyrockUsdcVault, + * sourceAdapterFixture, + * sourceMarket, + * targetAdapterFixture, + * targetMarket, + * 1_000_000n, + * 1_000_000_000_000_000n, + * ); + * // Bundler3 approves 1_000 USDC units, then calls `reallocate` with zero native value. + * ``` + */ + // biome-ignore lint/complexity/useMaxParams: mirrors the protocol call + export function vaultV2BluePublicAllocatorReallocate( + chainId: number, + vault: Address, + deallocateAdapter: Address, + deallocateMarket: InputMarketParams, + allocateAdapter: Address, + allocateMarket: InputMarketParams, + assets: bigint, + penalty: bigint, + skipRevert = false, + ): BundlerCall[] { + const { vaultV2BluePublicAllocator: allocator } = + getChainAddresses(chainId); + if (allocator == null) { + throw new BundlerErrors.UnexpectedAction( + "vaultV2BluePublicAllocatorReallocate", + chainId, + ); + } + const calls: BundlerCall[] = []; + const penaltyAssets = + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + { penalty }, + assets, + ); + if (skipRevert && penaltyAssets > 0n) { + throw new BundlerErrors.SkippableAllocatorPenalty(penaltyAssets); + } + + if (penaltyAssets > 0n) { + calls.push({ + to: allocateMarket.loanToken, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [allocator, penaltyAssets], + }), + value: 0n, + skipRevert, + callbackHash: zeroHash, + }); + } + + calls.push({ + to: allocator, + data: encodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + functionName: "reallocate", + args: [ + vault, + deallocateAdapter, + deallocateMarket, + allocateAdapter, + allocateMarket, + assets, + penalty, + ], + }), + value: 0n, + skipRevert, + callbackHash: zeroHash, + }); + + return calls; + } + + /** + * Encodes a Vault V2 Blue Public Allocator allocation from vault idle liquidity. + * + * @remarks Bundler3 must already hold the computed penalty assets. The + * high-level Blue builders add the corresponding GeneralAdapter1 transfer. + * + * @param chainId - Chain whose canonical Blue Public Allocator is called. + * @param vault - Vault whose idle liquidity is allocated. + * @param adapter - Vault V2 adapter supplying the target market. + * @param market - Target Morpho Blue market parameters. + * @param assets - Assets to allocate, bounded by `uint128` by the high-level action. + * @param penalty - Vault-configured proportional penalty, scaled by WAD. + * @param skipRevert - Whether Bundler3 should tolerate a revert. + * @returns An exact token approval when needed, followed by the allocator call. + * @throws {BundlerErrors.UnexpectedAction} when the chain has no Blue Public Allocator deployment. + * @throws {BundlerErrors.SkippableAllocatorPenalty} when `skipRevert` is true and a token approval is required. + * @example + * ```ts + * import { ChainId, type InputMarketParams } from "@morpho-org/blue-sdk"; + * import { + * BundlerAction, + * type BundlerCall, + * } from "@morpho-org/morpho-sdk/bundler"; + * import type { Address } from "viem"; + * + * const keyrockUsdcVault = + * "0x04422053aDDbc9bB2759b248B574e3FCA76Bc145" satisfies Address; + * const targetAdapterFixture = + * "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" satisfies Address; + * const usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" satisfies Address; + * const weth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" satisfies Address; + * const ethUsdOracle = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" satisfies Address; + * const adaptiveCurveIrm = "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC" satisfies Address; + * const targetMarket = { + * loanToken: usdc, + * collateralToken: weth, + * oracle: ethUsdOracle, + * irm: adaptiveCurveIrm, + * lltv: 860_000_000_000_000_000n, + * } satisfies InputMarketParams; + * + * const calls: BundlerCall[] = BundlerAction.vaultV2BluePublicAllocatorAllocateFromIdle( + * ChainId.EthMainnet, + * keyrockUsdcVault, + * targetAdapterFixture, + * targetMarket, + * 1_000_000n, + * 1_000_000_000_000_000n, + * ); + * // Bundler3 approves 1_000 USDC units, then calls `allocateFromIdle` with zero native value. + * ``` + */ + // biome-ignore lint/complexity/useMaxParams: mirrors the protocol call + export function vaultV2BluePublicAllocatorAllocateFromIdle( + chainId: number, + vault: Address, + adapter: Address, + market: InputMarketParams, + assets: bigint, + penalty: bigint, + skipRevert = false, + ): BundlerCall[] { + const { vaultV2BluePublicAllocator: allocator } = + getChainAddresses(chainId); + if (allocator == null) { + throw new BundlerErrors.UnexpectedAction( + "vaultV2BluePublicAllocatorAllocateFromIdle", + chainId, + ); + } + const calls: BundlerCall[] = []; + const penaltyAssets = + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + { penalty }, + assets, + ); + if (skipRevert && penaltyAssets > 0n) { + throw new BundlerErrors.SkippableAllocatorPenalty(penaltyAssets); + } + + if (penaltyAssets > 0n) { + calls.push({ + to: market.loanToken, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [allocator, penaltyAssets], + }), + value: 0n, + skipRevert, + callbackHash: zeroHash, + }); + } + + calls.push({ + to: allocator, + data: encodeFunctionData({ + abi: vaultV2BluePublicAllocatorAbi, + functionName: "allocateFromIdle", + args: [vault, adapter, market, assets, penalty], + }), + value: 0n, + skipRevert, + callbackHash: zeroHash, + }); + + return calls; + } + /** * Encodes a GeneralAdapter1 native-token wrap call. * diff --git a/packages/morpho-sdk/src/bundler/types.ts b/packages/morpho-sdk/src/bundler/types.ts index f07ea6718..10ca3438f 100644 --- a/packages/morpho-sdk/src/bundler/types.ts +++ b/packages/morpho-sdk/src/bundler/types.ts @@ -210,6 +210,28 @@ export interface ActionArgs { skipRevert?: boolean, ]; + /** Vault V2 Blue Public Allocator market-to-market reallocation with a WAD-scaled penalty. */ + readonly vaultV2BluePublicAllocatorReallocate: [ + vault: Address, + deallocateAdapter: Address, + deallocateMarket: InputMarketParams, + allocateAdapter: Address, + allocateMarket: InputMarketParams, + assets: bigint, + penalty: bigint, + skipRevert?: boolean, + ]; + + /** Vault V2 Blue Public Allocator idle-to-market allocation with a WAD-scaled penalty. */ + readonly vaultV2BluePublicAllocatorAllocateFromIdle: [ + vault: Address, + adapter: Address, + market: InputMarketParams, + assets: bigint, + penalty: bigint, + skipRevert?: boolean, + ]; + /** GeneralAdapter1 native wrap of `amount` to `recipient`; `skipRevert` controls Bundler3 revert handling. */ readonly wrapNative: [ amount: bigint, diff --git a/packages/morpho-sdk/src/constants.ts b/packages/morpho-sdk/src/constants.ts index 4351c39c6..6cf5e0fa3 100644 --- a/packages/morpho-sdk/src/constants.ts +++ b/packages/morpho-sdk/src/constants.ts @@ -39,9 +39,11 @@ export { export { APPROVE_ONLY_ONCE_TOKENS, DEFAULT_LLTV_BUFFER, + DEFAULT_MAX_REALLOCATION_PENALTY, DEFAULT_SUPPLY_TARGET_UTILIZATION, DEFAULT_WITHDRAWAL_TARGET_UTILIZATION, MAX_ABSOLUTE_SHARE_PRICE, + MAX_REALLOCATION_PENALTY, MAX_SLIPPAGE_TOLERANCE, MAX_TOKEN_APPROVALS, } from "./helpers/constant.js"; diff --git a/packages/morpho-sdk/src/entities/AGENTS.md b/packages/morpho-sdk/src/entities/AGENTS.md index 051711630..9438c3d97 100644 --- a/packages/morpho-sdk/src/entities/AGENTS.md +++ b/packages/morpho-sdk/src/entities/AGENTS.md @@ -15,6 +15,6 @@ See [`packages/morpho-sdk/AGENTS.md`](../../AGENTS.md) routing summary. ## Shared liquidity -`MorphoBlue.borrow()`, `supplyCollateralBorrow()`, `withdraw()`, and `refinance()` accept optional reallocations. The entity validates their state-independent shape before returning requirements, and the pure action repeats the same validation before encoding. `getReallocationData` may fetch the inputs needed to compute reallocations, but action encoding stays outside the entity fetch path. +`MorphoBlue.borrow()`, `supplyCollateralBorrow()`, `withdraw()`, and `refinance()` accept optional homogeneous V1-or-V2 reallocation plans; mixing versions is rejected. Consumer-supplied reallocation plans and vault allowlists accept any iterable and are normalized once before lazy or repeated use; ordered outputs remain readonly arrays. The entity validates their state-independent shape before returning requirements, and the pure action repeats the same validation before encoding. `getVaultV1ReallocationData` and `getVaultV2BlueReallocationData` fetch the versioned inputs needed to compute reallocations; deprecated `getReallocationData` delegates to the V1 fetcher. Action encoding stays outside every entity fetch path. -`ReallocationData` is the entity-level state container for public allocator simulations. Its public maps are readable snapshots for inspection; state transitions stay on its methods and return cloned `ReallocationData` instances. +`VaultV1ReallocationData` is the entity-level state container for PublicAllocator V1 simulations; `ReallocationData` remains its deprecated compatibility alias. `VaultV2BlueReallocationData` owns the separate BluePublicAllocator state model. Their public maps are readable snapshots for inspection; state transitions stay on their methods and return cloned instances of the same versioned class. diff --git a/packages/morpho-sdk/src/entities/blue/AGENTS.md b/packages/morpho-sdk/src/entities/blue/AGENTS.md index 8281c48ae..479249658 100644 --- a/packages/morpho-sdk/src/entities/blue/AGENTS.md +++ b/packages/morpho-sdk/src/entities/blue/AGENTS.md @@ -21,8 +21,9 @@ `getRequirements` returns: - ERC-20 approval for **GeneralAdapter1** on the collateral token (any path that supplies collateral) or the loan token (`supply`, `repay`, `repayWithdrawCollateral`). The approved amount is the **ERC-20 portion actually pulled**, not the total: for a native-funded repay it is `amount` (assets mode) or `max(0, toBorrowAssets(shares) − nativeAmount)` (shares mode — clamped at 0 so a `nativeAmount` that covers or exceeds the borrow assets pulls nothing). A fully-native repay pulls no ERC-20, so no approval requirement is emitted; in shares mode any wrapped native beyond the on-chain repay is skimmed back to the receiver. +- A classic ERC-20 approval for **GeneralAdapter1** on the loan token when `borrow`, `withdraw`, or `refinance` includes BluePublicAllocator reallocations with a non-zero penalty. `supplyCollateralBorrow` does the same when the collateral and loan tokens differ; when they are identical, it adds the penalty to the single collateral approval or permit and emits no separate penalty requirement. The approved amount is the sum of each call's independently rounded `ceil(assets × penalty / WAD)` donation. The separate-token path deliberately does not return a permit signature, so it can coexist with a collateral-token permit. - `morpho.setAuthorization(generalAdapter1, true)` when authorization is not yet set on Morpho — read via `publicActions`. Required for `borrow`, `supplyCollateralBorrow`, `repayWithdrawCollateral`, and `withdraw` (loan-asset). When `supportSignature` is enabled on the client, the authorization requirement is returned as a signable `Requirement` instead of a transaction; signing it produces an `AuthorizationRequirementSignature` that `buildTx` consumes and folds into the bundle as a `setAuthorizationWithSig` call, so no standalone authorization transaction is needed. `buildTx` accepts a `readonly RequirementSignature[]` and splits permit vs. authorization signatures via `isPermitSignature` / `isAuthorizationSignature`. -`withdrawCollateral` has no requirements. `repay` and `supply` need only loan-token approval (native wrapping requires the loan token to be the chain's wNative). Loan-asset `withdraw` needs only the Morpho authorization. +`withdrawCollateral` has no requirements. `repay` and `supply` need only loan-token approval (native wrapping requires the loan token to be the chain's wNative). Without V2 reallocations, loan-asset `withdraw` needs only the Morpho authorization. diff --git a/packages/morpho-sdk/src/entities/blue/blue.bluePublicAllocatorRequirements.test.ts b/packages/morpho-sdk/src/entities/blue/blue.bluePublicAllocatorRequirements.test.ts new file mode 100644 index 000000000..d609002df --- /dev/null +++ b/packages/morpho-sdk/src/entities/blue/blue.bluePublicAllocatorRequirements.test.ts @@ -0,0 +1,524 @@ +import { + AccrualPosition, + ChainId, + getChainAddresses, + Market, + MarketParams, + ORACLE_PRICE_SCALE, +} from "@morpho-org/blue-sdk"; +import { blueAbi, erc2612Abi, fetchToken } from "@morpho-org/blue-sdk-viem"; +import { createMockClient, mockRead } from "@morpho-org/test/mock"; +import { erc20Abi } from "viem"; +import { mainnet } from "viem/chains"; +import { describe, expect, test, vi } from "vitest"; +import { + CbbtcUsdcBlue, + CbbtcUsdcBlueAlt, +} from "../../../test/fixtures/blue.js"; +import { morphoViemExtension } from "../../client/index.js"; +import { + BundlerErrors, + isRequirementApproval, + isRequirementBlueAuthorization, + isRequirementSignature, +} from "../../types/index.js"; + +vi.mock("@morpho-org/blue-sdk-viem", async (importOriginal) => { + const original = + await importOriginal(); + return { ...original, fetchToken: vi.fn() }; +}); + +const USER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const marketParams = new MarketParams(CbbtcUsdcBlue); + +const makePosition = ( + params: MarketParams, + { + supplyShares = 0n, + borrowShares = 0n, + collateral = 0n, + }: { + supplyShares?: bigint; + borrowShares?: bigint; + collateral?: bigint; + }, +) => + new AccrualPosition( + { user: USER, supplyShares, borrowShares, collateral }, + new Market({ + params, + totalSupplyAssets: 10n ** 24n, + totalBorrowAssets: 10n ** 24n / 2n, + totalSupplyShares: 10n ** 24n, + totalBorrowShares: 10n ** 24n / 2n, + lastUpdate: 1_700_000_000n, + fee: 0n, + price: ORACLE_PRICE_SCALE, + }), + ); + +describe("MorphoBlue BluePublicAllocator requirements", () => { + test("error: unsupported V2 deployment across every action flow", () => { + const chain = { ...mainnet, id: ChainId.CronosMainnet }; + const handle = createMockClient(chain); + const market = handle.client + .extend(morphoViemExtension()) + .morpho.blue(marketParams, chain.id); + const reallocations = [ + { + vault: marketParams.oracle, + from: { type: "idle" }, + to: { adapter: marketParams.collateralToken }, + assets: 10n, + penalty: 0n, + }, + ] as const; + + const actionFlows = [ + () => + market.borrow({ + amount: 1n, + userAddress: USER, + positionData: makePosition(marketParams, { + borrowShares: 1n, + collateral: 1_000_000n, + }), + reallocations, + }), + () => + market.withdraw({ + assets: 1n, + userAddress: USER, + positionData: makePosition(marketParams, { supplyShares: 10n }), + reallocations, + }), + () => + market.supplyCollateralBorrow({ + amount: 100n, + borrowAmount: 1n, + userAddress: USER, + positionData: makePosition(marketParams, { + borrowShares: 1n, + collateral: 1_000_000n, + }), + reallocations, + }), + () => + market.refinance({ + userAddress: USER, + positionData: makePosition(marketParams, { + borrowShares: 10n, + collateral: 1_000n, + }), + target: { + marketParams: CbbtcUsdcBlueAlt, + positionData: makePosition(CbbtcUsdcBlueAlt, {}), + }, + collateralAmount: 100n, + borrowAssets: 1n, + targetReallocations: reallocations, + }), + ]; + + for (const actionFlow of actionFlows) { + expect(actionFlow).toThrow(BundlerErrors.UnexpectedAction); + } + }); + + test("default: includes the classic loan-token approval for V2 penalties", async () => { + const handle = createMockClient(mainnet); + const { + morpho, + bundler3: { generalAdapter1 }, + } = getChainAddresses(mainnet.id); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "isAuthorized", + result: true, + }); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "nonce", + result: 0n, + }); + mockRead(handle, { + address: marketParams.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + + const positionData = makePosition(marketParams, { + borrowShares: 10n ** 18n, + collateral: 10n ** 24n, + }); + const market = handle.client + .extend(morphoViemExtension({ supportSignature: true })) + .morpho.blue(CbbtcUsdcBlue, mainnet.id); + + const requirements = await market + .borrow({ + amount: 1n, + userAddress: USER, + positionData, + reallocations: [ + { + vault: CbbtcUsdcBlue.oracle, + from: { type: "idle" }, + to: { adapter: CbbtcUsdcBlue.collateralToken }, + assets: 1n, + penalty: 1n, + }, + ], + }) + .getRequirements(); + + const approval = requirements.find(isRequirementApproval); + expect(approval?.to).toBe(marketParams.loanToken); + expect(approval?.action.args).toStrictEqual({ + spender: generalAdapter1, + amount: 1n, + }); + }); + + test("behavior: supplyCollateralBorrow approves distinct collateral and penalty tokens", async () => { + const handle = createMockClient(mainnet); + const { + morpho, + bundler3: { generalAdapter1 }, + } = getChainAddresses(mainnet.id); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "isAuthorized", + result: true, + }); + mockRead(handle, { + address: marketParams.collateralToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + mockRead(handle, { + address: marketParams.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + const market = handle.client + .extend(morphoViemExtension({ supportSignature: false })) + .morpho.blue(marketParams, mainnet.id); + + const requirements = await market + .supplyCollateralBorrow({ + amount: 100n, + borrowAmount: 1n, + userAddress: USER, + positionData: makePosition(marketParams, { + borrowShares: 1n, + collateral: 1_000_000n, + }), + reallocations: [ + { + vault: marketParams.oracle, + from: { type: "idle" }, + to: { adapter: marketParams.collateralToken }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + ], + }) + .getRequirements(); + + expect( + requirements.filter(isRequirementApproval).map((approval) => ({ + token: approval.to, + ...approval.action.args, + })), + ).toStrictEqual([ + { + token: marketParams.collateralToken, + spender: generalAdapter1, + amount: 100n, + }, + { + token: marketParams.loanToken, + spender: generalAdapter1, + amount: 5n, + }, + ]); + }); + + test("behavior: withdraw includes V2 penalty approval and Morpho authorization", async () => { + const handle = createMockClient(mainnet); + const { + morpho, + bundler3: { generalAdapter1 }, + } = getChainAddresses(mainnet.id); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "isAuthorized", + result: false, + }); + mockRead(handle, { + address: marketParams.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + const market = handle.client + .extend(morphoViemExtension({ supportSignature: false })) + .morpho.blue(marketParams, mainnet.id); + + const requirements = await market + .withdraw({ + assets: 1n, + userAddress: USER, + positionData: makePosition(marketParams, { supplyShares: 10n }), + reallocations: [ + { + vault: marketParams.oracle, + from: { type: "idle" }, + to: { adapter: marketParams.collateralToken }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + ], + }) + .getRequirements(); + + const approval = requirements.find(isRequirementApproval); + const authorization = requirements.find(isRequirementBlueAuthorization); + expect(approval?.to).toBe(marketParams.loanToken); + expect(approval?.action.args).toStrictEqual({ + spender: generalAdapter1, + amount: 5n, + }); + expect(authorization?.action.args).toStrictEqual({ + authorized: generalAdapter1, + isAuthorized: true, + }); + }); + + test("behavior: refinance includes V2 penalty approval and Morpho authorization", async () => { + const handle = createMockClient(mainnet); + const { + morpho, + bundler3: { generalAdapter1 }, + } = getChainAddresses(mainnet.id); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "isAuthorized", + result: false, + }); + mockRead(handle, { + address: marketParams.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + const market = handle.client + .extend(morphoViemExtension({ supportSignature: false })) + .morpho.blue(marketParams, mainnet.id); + + const requirements = await market + .refinance({ + userAddress: USER, + positionData: makePosition(marketParams, { + borrowShares: 10n, + collateral: 1_000n, + }), + target: { + marketParams: CbbtcUsdcBlueAlt, + positionData: makePosition(CbbtcUsdcBlueAlt, {}), + }, + collateralAmount: 100n, + borrowAssets: 1n, + targetReallocations: [ + { + vault: marketParams.oracle, + from: { type: "idle" }, + to: { adapter: marketParams.collateralToken }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + ], + }) + .getRequirements(); + + const approval = requirements.find(isRequirementApproval); + const authorization = requirements.find(isRequirementBlueAuthorization); + expect(approval?.to).toBe(marketParams.loanToken); + expect(approval?.action.args).toStrictEqual({ + spender: generalAdapter1, + amount: 5n, + }); + expect(authorization?.action.args).toStrictEqual({ + authorized: generalAdapter1, + isAuthorized: true, + }); + }); + + test("behavior: aggregates collateral and penalty into one shared-token approval", async () => { + const sharedTokenParams = new MarketParams({ + ...CbbtcUsdcBlue, + collateralToken: CbbtcUsdcBlue.loanToken, + }); + const handle = createMockClient(mainnet); + const { + morpho, + bundler3: { generalAdapter1 }, + } = getChainAddresses(mainnet.id); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "isAuthorized", + result: true, + }); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "nonce", + result: 0n, + }); + mockRead(handle, { + address: sharedTokenParams.loanToken, + abi: erc20Abi, + functionName: "allowance", + result: 0n, + }); + const positionData = new AccrualPosition( + { + user: USER, + supplyShares: 0n, + borrowShares: 1n, + collateral: 1_000_000n, + }, + new Market({ + params: sharedTokenParams, + totalSupplyAssets: 1_000_000n, + totalBorrowAssets: 1n, + totalSupplyShares: 1_000_000n, + totalBorrowShares: 1n, + lastUpdate: 1_700_000_000n, + fee: 0n, + price: ORACLE_PRICE_SCALE, + }), + ); + const market = handle.client + .extend(morphoViemExtension({ supportSignature: false })) + .morpho.blue(sharedTokenParams, mainnet.id); + + const requirements = await market + .supplyCollateralBorrow({ + amount: 100n, + borrowAmount: 1n, + userAddress: USER, + positionData, + reallocations: [ + { + vault: CbbtcUsdcBlue.oracle, + from: { type: "idle" }, + to: { adapter: CbbtcUsdcBlue.collateralToken }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + ], + }) + .getRequirements(); + + const approvals = requirements.filter(isRequirementApproval); + expect(approvals).toHaveLength(1); + expect(approvals[0]?.to).toBe(sharedTokenParams.loanToken); + expect(approvals[0]?.action.args).toStrictEqual({ + spender: generalAdapter1, + amount: 105n, + }); + }); + + test("behavior: aggregates collateral and penalty into one shared-token simple permit", async () => { + const sharedTokenParams = new MarketParams({ + ...CbbtcUsdcBlue, + collateralToken: CbbtcUsdcBlue.loanToken, + }); + const handle = createMockClient(mainnet); + const { morpho } = getChainAddresses(mainnet.id); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "isAuthorized", + result: true, + }); + mockRead(handle, { + address: morpho, + abi: blueAbi, + functionName: "nonce", + result: 0n, + }); + mockRead(handle, { + address: sharedTokenParams.loanToken, + abi: erc2612Abi, + functionName: "nonces", + result: 0n, + }); + vi.mocked(fetchToken).mockResolvedValue({ + address: sharedTokenParams.loanToken, + decimals: 6, + symbol: "USDC", + name: "USD Coin", + fromUsd: () => 0n, + toUsd: () => 0n, + }); + const positionData = new AccrualPosition( + { + user: USER, + supplyShares: 0n, + borrowShares: 1n, + collateral: 1_000_000n, + }, + new Market({ + params: sharedTokenParams, + totalSupplyAssets: 1_000_000n, + totalBorrowAssets: 1n, + totalSupplyShares: 1_000_000n, + totalBorrowShares: 1n, + lastUpdate: 1_700_000_000n, + fee: 0n, + price: ORACLE_PRICE_SCALE, + }), + ); + const market = handle.client + .extend(morphoViemExtension({ supportSignature: true })) + .morpho.blue(sharedTokenParams, mainnet.id); + + const requirements = await market + .supplyCollateralBorrow({ + amount: 100n, + borrowAmount: 1n, + userAddress: USER, + positionData, + reallocations: [ + { + vault: CbbtcUsdcBlue.oracle, + from: { type: "idle" }, + to: { adapter: CbbtcUsdcBlue.collateralToken }, + assets: 10n, + penalty: 500_000_000_000_000_000n, + }, + ], + }) + .getRequirements({ useSimplePermit: true }); + + const permits = requirements.filter(isRequirementSignature); + expect(permits).toHaveLength(1); + expect(permits[0]?.action).toMatchObject({ + type: "permit", + args: { amount: 105n }, + }); + }); +}); diff --git a/packages/morpho-sdk/src/entities/blue/blue.inputValidation.test.ts b/packages/morpho-sdk/src/entities/blue/blue.inputValidation.test.ts index 5c14ff9b7..7d1e06844 100644 --- a/packages/morpho-sdk/src/entities/blue/blue.inputValidation.test.ts +++ b/packages/morpho-sdk/src/entities/blue/blue.inputValidation.test.ts @@ -35,7 +35,7 @@ describe("MorphoBlue reallocation input validation", () => { assets: 1n, userAddress: USER, positionData: undefined as never, - reallocations: INVALID_REALLOCATIONS, + reallocations: INVALID_REALLOCATIONS.values(), }), ).toThrow(NegativeInputError); }); @@ -48,7 +48,7 @@ describe("MorphoBlue reallocation input validation", () => { amount: 1n, userAddress: USER, positionData: undefined as never, - reallocations: INVALID_REALLOCATIONS, + reallocations: INVALID_REALLOCATIONS.values(), }), ).toThrow(NegativeInputError); }); @@ -62,7 +62,7 @@ describe("MorphoBlue reallocation input validation", () => { borrowAmount: 1n, userAddress: USER, positionData: undefined as never, - reallocations: INVALID_REALLOCATIONS, + reallocations: INVALID_REALLOCATIONS.values(), }), ).toThrow(NegativeInputError); }); @@ -79,7 +79,7 @@ describe("MorphoBlue reallocation input validation", () => { positionData: undefined as never, }, collateralAmount: 1n, - targetReallocations: INVALID_REALLOCATIONS, + targetReallocations: INVALID_REALLOCATIONS.values(), }), ).toThrow(NegativeInputError); }); diff --git a/packages/morpho-sdk/src/entities/blue/blue.reallocations.test.ts b/packages/morpho-sdk/src/entities/blue/blue.reallocations.test.ts index f1fc7869d..c7eae3a50 100644 --- a/packages/morpho-sdk/src/entities/blue/blue.reallocations.test.ts +++ b/packages/morpho-sdk/src/entities/blue/blue.reallocations.test.ts @@ -1,13 +1,15 @@ +import { MarketParams } from "@morpho-org/blue-sdk"; import { createPublicClient, http } from "viem"; import { mainnet } from "viem/chains"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { CbbtcUsdcBlue } from "../../../test/fixtures/blue.js"; import { morphoViemExtension } from "../../client/index.js"; import { ChainIdMismatchError } from "../../types/index.js"; -import { ReallocationData } from "../reallocationData.js"; +import { VaultV1ReallocationData } from "../vaultV1ReallocationData.js"; +import { VaultV2BlueReallocationData } from "../vaultV2BlueReallocationData.js"; -describe("MorphoBlue.getReallocations", () => { - test("error: ChainIdMismatchError when reallocation data chain differs from market chain", () => { +describe("MorphoBlue reallocation APIs", () => { + test("getVaultV1Reallocations error: ChainIdMismatchError", () => { const publicClient = createPublicClient({ chain: mainnet, transport: http("https://rpc.example"), @@ -16,10 +18,115 @@ describe("MorphoBlue.getReallocations", () => { const market = morphoClient.blue(CbbtcUsdcBlue, mainnet.id); expect(() => - market.getReallocations({ - reallocationData: new ReallocationData({ chainId: mainnet.id + 1 }), + market.getVaultV1Reallocations({ + reallocationData: new VaultV1ReallocationData({ + chainId: mainnet.id + 1, + }), borrowAmount: 1n, }), ).toThrow(ChainIdMismatchError); }); + + test("deprecated getReallocations delegates to the Vault V1 planner", () => { + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://rpc.example"), + }); + const market = publicClient + .extend(morphoViemExtension()) + .morpho.blue(CbbtcUsdcBlue, mainnet.id); + const expected = [] as const; + const canonical = vi + .spyOn(market, "getVaultV1Reallocations") + .mockReturnValue(expected); + const params = { + reallocationData: new VaultV1ReallocationData({ chainId: mainnet.id }), + borrowAmount: 1n, + } as const; + + expect(market.getReallocations(params)).toBe(expected); + expect(canonical).toHaveBeenCalledWith(params); + }); + + test("deprecated getReallocationData delegates to the Vault V1 fetcher", async () => { + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://rpc.example"), + }); + const market = publicClient + .extend(morphoViemExtension()) + .morpho.blue(CbbtcUsdcBlue, mainnet.id); + const expected = new VaultV1ReallocationData({ chainId: mainnet.id }); + const canonical = vi + .spyOn(market, "getVaultV1ReallocationData") + .mockResolvedValue(expected); + const params = { + vaultAddresses: [], + block: { number: 0n, timestamp: 0n }, + } as const; + + await expect(market.getReallocationData(params)).resolves.toBe(expected); + expect(canonical).toHaveBeenCalledWith(params); + }); + + test("error: getVaultV2BlueReallocationData validates the client chain", async () => { + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://rpc.example"), + }); + const market = publicClient + .extend(morphoViemExtension()) + .morpho.blue(CbbtcUsdcBlue, mainnet.id + 1); + + await expect( + market.getVaultV2BlueReallocationData({ + vaultAddresses: [], + block: { number: 0n, timestamp: 0n }, + }), + ).rejects.toBeInstanceOf(ChainIdMismatchError); + }); + + test("getVaultV2BlueReallocations default: delegates to the Vault V2 planner", () => { + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://rpc.example"), + }); + const market = publicClient + .extend(morphoViemExtension()) + .morpho.blue(CbbtcUsdcBlue, mainnet.id); + const reallocationData = new VaultV2BlueReallocationData({ + chainId: mainnet.id, + }); + const expected = { reallocations: [], data: reallocationData } as const; + const planner = vi + .spyOn(reallocationData, "computeVaultV2BlueReallocations") + .mockReturnValue(expected); + const options = { enabled: false } as const; + + expect( + market.getVaultV2BlueReallocations({ reallocationData, options }), + ).toBe(expected); + expect(planner).toHaveBeenCalledWith( + new MarketParams(CbbtcUsdcBlue).id, + options, + ); + }); + + test("getVaultV2BlueReallocations error: ChainIdMismatchError", () => { + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://rpc.example"), + }); + const market = publicClient + .extend(morphoViemExtension()) + .morpho.blue(CbbtcUsdcBlue, mainnet.id); + + expect(() => + market.getVaultV2BlueReallocations({ + reallocationData: new VaultV2BlueReallocationData({ + chainId: mainnet.id + 1, + }), + }), + ).toThrow(ChainIdMismatchError); + }); }); diff --git a/packages/morpho-sdk/src/entities/blue/blue.test.ts b/packages/morpho-sdk/src/entities/blue/blue.test.ts index 82db773f0..5dcf8606a 100644 --- a/packages/morpho-sdk/src/entities/blue/blue.test.ts +++ b/packages/morpho-sdk/src/entities/blue/blue.test.ts @@ -28,7 +28,7 @@ import { NonPositiveInputError, WithdrawExceedsCollateralError, } from "../../types/index.js"; -import { ReallocationData } from "../reallocationData.js"; +import { VaultV1ReallocationData } from "../vaultV1ReallocationData.js"; const MARKET_PARAMS = new MarketParams(CbbtcUsdcBlue); const USER: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; @@ -666,7 +666,7 @@ describe("MorphoBlue validation", () => { ).toThrow(NonPositiveInputError); }); - test("getReallocations accepts the operation/amount parameter shape", async ({ + test("getVaultV1Reallocations accepts the operation/amount parameter shape", async ({ client, }) => { const market = client @@ -674,8 +674,8 @@ describe("MorphoBlue validation", () => { .morpho.blue(CbbtcUsdcBlue, mainnet.id); expect( - market.getReallocations({ - reallocationData: new ReallocationData({ chainId: mainnet.id }), + market.getVaultV1Reallocations({ + reallocationData: new VaultV1ReallocationData({ chainId: mainnet.id }), operation: "borrow", amount: 1n, options: { enabled: false }, diff --git a/packages/morpho-sdk/src/entities/blue/blue.ts b/packages/morpho-sdk/src/entities/blue/blue.ts index 5e17fa2e7..5995d7cb3 100644 --- a/packages/morpho-sdk/src/entities/blue/blue.ts +++ b/packages/morpho-sdk/src/entities/blue/blue.ts @@ -8,13 +8,16 @@ import { type Position, type Vault, type VaultMarketConfig, + VaultV2BluePublicAllocatorConfigUtils, } from "@morpho-org/blue-sdk"; import { fetchAccrualPosition, + fetchAccrualVaultV2, fetchMarket, fetchPosition, fetchVault, fetchVaultMarketConfig, + fetchVaultV2BluePublicAllocatorData, } from "@morpho-org/blue-sdk-viem"; import { Time } from "@morpho-org/morpho-ts"; import { type Address, isAddressEqual } from "viem"; @@ -36,24 +39,25 @@ import { computeMaxSupplySharePrice, computeMinBorrowSharePrice, computeMinWithdrawSharePrice, - computeReallocations, + computeVaultV1Reallocations, validateAccrualPosition, validateChainId, validateNativeAsset, validatePositionHealth, validatePositionHealthAfterWithdraw, - validateReallocations, validateRepayAmount, validateRepayShares, validateSlippageTolerance, validateWithdrawAmount, validateWithdrawShares, } from "../../helpers/index.js"; +import { validateAndNormalizeReallocations } from "../../helpers/validate.js"; import type { FetchParameters } from "../../types/data.js"; import { type AssetsOrSharesArgs, type BlueAuthorizationAction, type BlueBorrowAction, + type BlueReallocationPlan, type BlueRefinanceAction, type BlueRepayAction, type BlueRepayWithdrawCollateralAction, @@ -84,10 +88,40 @@ import { type RequirementSignature, selectRequirementSignatures, type Transaction, - type VaultReallocation, + type VaultV1Reallocation, + type VaultV2BluePublicAllocatorOptions, + type VaultV2BlueReallocation, WithdrawExceedsCollateralError, } from "../../types/index.js"; -import { ReallocationData } from "../reallocationData.js"; +import { VaultV1ReallocationData } from "../vaultV1ReallocationData.js"; +import { VaultV2BlueReallocationData } from "../vaultV2BlueReallocationData.js"; + +type VaultV1ReallocationsParams = { + readonly reallocationData: VaultV1ReallocationData; + readonly options?: ReallocationComputeOptions; +} & ( + | { + readonly operation: "borrow" | "withdraw"; + readonly amount: bigint; + readonly borrowAmount?: never; + } + | { + /** @deprecated Pass `{ operation: "borrow", amount }` instead. */ + readonly borrowAmount: bigint; + readonly operation?: never; + readonly amount?: never; + } +); + +type VaultV2BlueReallocationsParams = { + readonly reallocationData: VaultV2BlueReallocationData; + readonly options?: VaultV2BluePublicAllocatorOptions & { + readonly operation?: { + readonly type: "borrow" | "withdraw"; + readonly amount: bigint; + }; + }; +}; export interface BlueActions { /** @@ -187,18 +221,25 @@ export interface BlueActions { * * Computes `minSharePrice` from market supply state and `slippageTolerance`. * - * When `reallocations` is provided, `reallocateTo` actions are prepended to the bundle, - * moving liquidity from other markets via the PublicAllocator before withdrawing — used to - * unblock withdraws that exceed on-market liquidity. + * When `reallocations` is provided, its homogeneous V1 or V2 actions are + * prepended to move liquidity before withdrawing. V1 fees add + * to the transaction value; V2 penalties are paid in the loan token. * - * `getRequirements` returns `morpho.setAuthorization(generalAdapter1, true)` if GA1 is not - * yet authorized on Morpho (returns `[]` when already authorized), since the bundler calls - * `withdraw(...,onBehalf=user,...)`. + * `getRequirements` returns the loan-token approval needed for V2 penalties + * and `morpho.setAuthorization(generalAdapter1, true)` when GA1 is not yet + * authorized on Morpho. * * **Stale `positionData` may cause unexpected supply share calculations.** * * @param params - Withdraw parameters including pre-fetched `positionData`. * @returns Object with `buildTx` and `getRequirements`. + * @throws {BundlerErrors.UnexpectedAction} when a V2 plan is unsupported on the chain. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. */ withdraw: ( params: { @@ -206,14 +247,18 @@ export interface BlueActions { receiver?: Address; positionData: AccrualPosition; slippageTolerance?: bigint; - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; } & AssetsOrSharesArgs, ) => { buildTx: ( signatures?: readonly RequirementSignature[], ) => Readonly>; getRequirements: () => Promise< - (Readonly> | Requirement)[] + ( + | Readonly> + | Readonly> + | Requirement + )[] >; }; @@ -224,29 +269,41 @@ export interface BlueActions { * Validates position health with LLTV buffer (0.5%) using the pre-fetched `positionData`. * Computes `minSharePrice` from market borrow state and `slippageTolerance`. * - * When `reallocations` is provided, `reallocateTo` actions are prepended to the bundle, - * moving liquidity from other markets via the PublicAllocator before borrowing. + * When `reallocations` is provided, its homogeneous V1 or V2 actions are + * prepended before borrowing. V1 fees add to the transaction + * value; V2 penalties are paid in the loan token. * - * `getRequirements` returns `morpho.setAuthorization(generalAdapter1, true)` if not yet authorized, - * since borrowing through bundler3 requires GeneralAdapter1 authorization on Morpho. + * `getRequirements` returns the loan-token approval needed for V2 penalties + * and Morpho authorization for GeneralAdapter1 when needed. * * **Stale `positionData` may cause unexpected health.** * * @param params - Borrow parameters including pre-fetched `positionData` for health validation. * @returns Object with `buildTx` and `getRequirements`. + * @throws {BundlerErrors.UnexpectedAction} when a V2 plan is unsupported on the chain. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. */ borrow: (params: { userAddress: Address; amount: bigint; positionData: AccrualPosition; slippageTolerance?: bigint; - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; }) => { buildTx: ( signatures?: readonly RequirementSignature[], ) => Readonly>; getRequirements: () => Promise< - (Readonly> | Requirement)[] + ( + | Readonly> + | Readonly> + | Requirement + )[] >; }; @@ -365,17 +422,26 @@ export interface BlueActions { * Routed through the bundler. Validates position health with LLTV buffer * to prevent instant liquidation on new positions near the LLTV threshold. * - * When `reallocations` is provided, `reallocateTo` actions are prepended before - * `morphoBorrow` in the bundle. + * When `reallocations` is provided, its homogeneous V1 or V2 actions run + * between the collateral supply and `morphoBorrow`. V1 fees add + * to the transaction value; V2 penalties are paid in the loan token. * * `getRequirements` returns in parallel: * - ERC20 approval or permit for collateral token (to GeneralAdapter1). + * - Classic ERC20 approval for any V2 loan-token penalties. * - `morpho.setAuthorization(generalAdapter1, true)` if adapter is not yet authorized. * * **Stale `positionData` may cause unexpected health.** * * @param params - Combined parameters including pre-fetched `positionData` for health validation. * @returns Object with `buildTx` and `getRequirements`. + * @throws {BundlerErrors.UnexpectedAction} when a V2 plan is unsupported on the chain. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. */ supplyCollateralBorrow: ( params: { @@ -383,7 +449,7 @@ export interface BlueActions { positionData: AccrualPosition; borrowAmount: bigint; slippageTolerance?: bigint; - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; } & DepositAmountArgs, ) => { buildTx: ( @@ -413,9 +479,12 @@ export interface BlueActions { * both the residual source and the aggregate target position stay within LLTV − buffer. Both * markets are forward-accrued to `now`; in shares mode the target borrow is overshot by * `slippageTolerance` and the callback sweeps the residual. + * A homogeneous V1 or V2 target reallocation plan runs first; V1 fees add + * to the transaction value and V2 penalties are paid + * in the loan token. * - * `getRequirements` returns `morpho.setAuthorization(generalAdapter1, true)` when GA1 is not yet - * authorized (a single global authorization covers both markets). + * `getRequirements` returns the loan-token approval needed for V2 penalties + * and Morpho authorization for GeneralAdapter1 when needed. * * @param params.userAddress - Position owner on both markets. * @param params.positionData - Pre-fetched source-market accrual position. @@ -425,8 +494,15 @@ export interface BlueActions { * @param params.borrowAssets - Loan assets to repay on source; exclusive with `borrowShares`. * @param params.borrowShares - Borrow shares to repay on source; exclusive with `borrowAssets`. * @param params.slippageTolerance - WAD slippage tolerance. Defaults to `DEFAULT_SLIPPAGE_TOLERANCE`. - * @param params.targetReallocations - PublicAllocator reallocations into the target market. + * @param params.targetReallocations - Homogeneous Vault V1 or Vault V2 reallocations into the target market. * @returns Object with `buildTx` and `getRequirements`. + * @throws {BundlerErrors.UnexpectedAction} when a V2 plan is unsupported on the chain. + * @throws {InputExceedsMaxError} when a V2 reallocation asset amount exceeds `uint128` or its penalty exceeds WAD. + * @throws {InconsistentReallocationPenaltyError} when V2 entries for one vault use different penalties. + * @throws {InvalidReallocationAddressError} when a V2 vault or adapter address is malformed. + * @throws {InvalidReallocationSourceTypeError} when a V2 source is absent, incomplete, or has an unknown discriminator. + * @throws {InvalidReallocationShapeError} when an entry matches both or neither V1/V2 shape. + * @throws {MixedReallocationVersionsError} when one plan contains both V1 and V2 entries. */ refinance: (params: { userAddress: Address; @@ -439,18 +515,22 @@ export interface BlueActions { borrowAssets?: bigint; borrowShares?: bigint; slippageTolerance?: bigint; - targetReallocations?: readonly VaultReallocation[]; + targetReallocations?: BlueReallocationPlan; }) => { buildTx: ( signatures?: readonly RequirementSignature[], ) => Readonly>; getRequirements: () => Promise< - (Readonly> | Requirement)[] + ( + | Readonly> + | Readonly> + | Requirement + )[] >; }; /** - * Fetches all on-chain data needed to construct a {@link ReallocationData} + * Fetches all on-chain data needed to construct a {@link VaultV1ReallocationData} * for computing vault reallocations via the public allocator. * * The target market is refetched internally at `block.number` so the @@ -459,27 +539,65 @@ export interface BlueActions { * inject unnecessary `reallocateTo` actions (and their PublicAllocator * fees) into the resulting bundle. * - * The returned reallocation data can be passed to {@link getReallocations} - * to compute the `VaultReallocation[]` array for `borrow()` or + * The returned reallocation data can be passed to {@link getVaultV1Reallocations} + * to compute the `VaultV1Reallocation[]` array for `borrow()` or * `supplyCollateralBorrow()`. * * **Stale data reverts on-chain (fail-safe).** * * @param params.vaultAddresses - Addresses of MetaMorpho vaults that allocate to this market. * @param params.block - The block to fetch data at (number and timestamp). - * @returns A ReallocationData instance populated with all required data. + * @returns A VaultV1ReallocationData instance populated with all required data. * @throws {ChainIdMismatchError} when the client chain does not match this market. */ + getVaultV1ReallocationData: (params: { + vaultAddresses: readonly Address[]; + block: { + readonly number: bigint; + readonly timestamp: bigint; + }; + }) => Promise; + + /** + * Fetches Vault V1 PublicAllocator state using the deprecated unversioned name. + * + * @param params.vaultAddresses - Addresses of MetaMorpho vaults that allocate to this market. + * @param params.block.number - Block number used for every RPC read. + * @param params.block.timestamp - Timestamp corresponding to the fetched block. + * @returns A `VaultV1ReallocationData` snapshot populated from one block. + * @throws {ChainIdMismatchError} when the client chain does not match this market. + * @deprecated Use {@link getVaultV1ReallocationData} instead. + */ getReallocationData: (params: { vaultAddresses: readonly Address[]; block: { readonly number: bigint; readonly timestamp: bigint; }; - }) => Promise; + }) => Promise; + + /** + * Fetches Vault V2 BluePublicAllocator state for this target market. + * + * Reads the target Morpho Blue market, each Vault V2 accrual tree, and each + * vault's BluePublicAllocator permissions and allocation caps at one block. + * + * @param params.vaultAddresses - Vault V2 addresses to inspect for market or idle liquidity. + * @param params.block.number - Block number used for every RPC read. + * @param params.block.timestamp - Timestamp corresponding to the fetched block. + * @returns A `VaultV2BlueReallocationData` snapshot ready for {@link getVaultV2BlueReallocations}. + * @throws {ChainIdMismatchError} when the client chain does not match this market. + */ + getVaultV2BlueReallocationData: (params: { + vaultAddresses: readonly Address[]; + block: { + readonly number: bigint; + readonly timestamp: bigint; + }; + }) => Promise; /** - * Computes vault reallocations for a borrow or withdraw on this market. + * Computes Vault V1 PublicAllocator reallocations for this market. * * Uses the shared-liquidity algorithm to determine which vaults should reallocate liquidity to * this market via the PublicAllocator, based on the post-operation utilization target. @@ -487,7 +605,7 @@ export interface BlueActions { * Pass `{ borrowAmount }` for a borrow (legacy alias, equivalent to `{ operation: "borrow", * amount }`) or `{ operation: "withdraw", amount }` for a loan-asset withdraw. * - * @param params.reallocationData - The current on-chain state (from {@link getReallocationData}). + * @param params.reallocationData - The current on-chain state (from {@link getVaultV1ReallocationData}). * @param params.operation - The operation driving the reallocation (`"borrow"` or `"withdraw"`). * Defaults to `"borrow"` when `borrowAmount` is provided. * @param params.amount - The borrow or withdraw amount used to compute the post-state utilization. @@ -500,27 +618,78 @@ export interface BlueActions { * or `withdraw()`. Empty array if no reallocation is needed. * @throws {ChainIdMismatchError} when `reallocationData` belongs to a different chain than this market. * @throws {InsufficientSharedLiquidityError} when shared liquidity cannot cover the operation's absolute shortfall on the target market — preventing fee-bearing reallocations from being attached to a call that would still revert onchain. + * @throws {ReallocationWithdrawExceedsMarketSupplyError} when a withdrawal exceeds the target market supply. * @throws {MissingPublicAllocatorConfigError} when a selected vault is missing its public allocator config. * @throws {UnknownReallocationMarketError} when the target market is absent from the reallocation data. + * @example + * ```ts + * const reallocations = market.getVaultV1Reallocations({ + * reallocationData, + * operation: "borrow", + * amount: 1_000_000n, + * }); + * ``` + */ + getVaultV1Reallocations: ( + params: VaultV1ReallocationsParams, + ) => readonly VaultV1Reallocation[]; + + /** + * Computes Vault V1 PublicAllocator reallocations using the deprecated unversioned name. + * + * @param params.reallocationData - State returned by {@link getVaultV1ReallocationData}. + * @param params.operation - The operation driving the reallocation (`"borrow"` or `"withdraw"`). + * @param params.amount - The borrow or withdraw amount used to compute post-state utilization. + * @param params.borrowAmount - Deprecated borrow amount alias. + * @param params.options - Optional allocator and utilization options. + * @returns Vault V1 reallocations ready for a Blue action. + * @throws {ChainIdMismatchError} when `reallocationData` belongs to another chain. + * @throws {InsufficientSharedLiquidityError} when shared liquidity cannot cover the operation. + * @throws {ReallocationWithdrawExceedsMarketSupplyError} when a withdrawal exceeds market supply. + * @throws {MissingPublicAllocatorConfigError} when a selected vault lacks allocator state. + * @throws {UnknownReallocationMarketError} when the target market is absent. + * @deprecated Use {@link getVaultV1Reallocations} instead. + * @example + * ```ts + * const reallocations = market.getReallocations({ + * reallocationData, + * operation: "borrow", + * amount: 1_000_000n, + * }); + * ``` */ getReallocations: ( - params: { - reallocationData: ReallocationData; - options?: ReallocationComputeOptions; - } & ( - | { - operation: "borrow" | "withdraw"; - amount: bigint; - borrowAmount?: never; - } - | { - /** @deprecated Pass `{ operation: "borrow", amount }` instead. */ - borrowAmount: bigint; - operation?: never; - amount?: never; - } - ), - ) => readonly VaultReallocation[]; + params: VaultV1ReallocationsParams, + ) => readonly VaultV1Reallocation[]; + + /** + * Computes Vault V2 BluePublicAllocator reallocations for this market. + * + * @param params.reallocationData - State returned by {@link getVaultV2BlueReallocationData}. + * @param params.options - Optional allocator discovery controls and operation to support. + * @returns Action-ready reallocations and their post-simulation state. + * @throws {ChainIdMismatchError} when `reallocationData` belongs to another chain. + * @throws {NegativeInputError} when a utilization or penalty limit is negative. + * @throws {InputExceedsMaxError} when a utilization or penalty limit exceeds WAD. + * @throws {NonPositiveInputError} when an enabled operation amount is not positive. + * @throws {UnknownReallocationMarketError} when a required market is absent. + * @throws {UnknownReallocationVaultError} when configured vault state is absent. + * @throws {UnknownReallocationPublicAllocatorConfigError} when allocator authorization state is absent. + * @throws {UnknownReallocationActiveAdaptersError} when active-adapter state is absent. + * @throws {InsufficientSharedLiquidityError} when selected liquidity cannot cover the shortfall. + * @throws {ReallocationWithdrawExceedsMarketSupplyError} when a withdrawal exceeds market supply. + * @example + * ```ts + * const result = market.getVaultV2BlueReallocations({ + * reallocationData, + * options: { operation: { type: "borrow", amount: 1_000_000n } }, + * }); + * ``` + */ + getVaultV2BlueReallocations: (params: VaultV2BlueReallocationsParams) => { + readonly reallocations: readonly VaultV2BlueReallocation[]; + readonly data: VaultV2BlueReallocationData; + }; } export class MorphoBlue implements BlueActions { @@ -531,6 +700,34 @@ export class MorphoBlue implements BlueActions { private readonly chainId: number, ) {} + private getReallocationPenaltyRequirements( + userAddress: Address, + reallocationPlan: ReturnType, + ) { + if (reallocationPlan.type === "vaultV1") return []; + + const { reallocations } = reallocationPlan; + const amount = reallocations.reduce( + (total, reallocation) => + total + + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + reallocation, + reallocation.assets, + ), + 0n, + ); + + // Separate-token penalty funding uses a classic GeneralAdapter1 allowance so a collateral + // permit and a loan-token penalty can coexist in one bundle. The shared-token path aggregates + // both amounts into the collateral requirement instead. + return getGeneralAdapterRequirements(this.client.viemClient, { + address: this.marketParams.loanToken, + chainId: this.chainId, + supportSignature: false, + args: { amount, from: userAddress }, + }); + } + async getMarketData(parameters?: FetchParameters): Promise { validateChainId(this.client.viemClient.chain?.id, this.chainId); @@ -642,7 +839,7 @@ export class MorphoBlue implements BlueActions { receiver?: Address; positionData: AccrualPosition; slippageTolerance?: bigint; - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; } & AssetsOrSharesArgs, ) { validateChainId(this.client.viemClient.chain?.id, this.chainId); @@ -654,6 +851,12 @@ export class MorphoBlue implements BlueActions { slippageTolerance = DEFAULT_SLIPPAGE_TOLERANCE, reallocations, } = params; + const reallocationPlan = validateAndNormalizeReallocations({ + reallocations, + targetMarketId: this.marketParams.id, + chainId: this.chainId, + }); + const reallocationList = reallocationPlan.reallocations; // Mode normalization: a missing or undefined `assets`/`shares` key collapses to `0n` // so the mutual-exclusion and positivity checks below are pure value comparisons. @@ -678,10 +881,6 @@ export class MorphoBlue implements BlueActions { } validateSlippageTolerance(slippageTolerance); - if (reallocations) { - validateReallocations(reallocations, this.marketParams.id); - } - if (!positionData) { throw new MissingAccrualPositionError(this.marketParams.id); } @@ -715,13 +914,19 @@ export class MorphoBlue implements BlueActions { return { getRequirements: async () => { - const authTx = await getBlueAuthorizationRequirement({ - viemClient: this.client.viemClient, - chainId: this.chainId, - userAddress, - supportSignature: this.client.options.supportSignature, - }); - return authTx ? [authTx] : []; + const [penaltyRequirements, authTx] = await Promise.all([ + this.getReallocationPenaltyRequirements( + userAddress, + reallocationPlan, + ), + getBlueAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + userAddress, + supportSignature: this.client.options.supportSignature, + }), + ]); + return [...penaltyRequirements, ...(authTx ? [authTx] : [])]; }, buildTx: (signatures?: readonly RequirementSignature[]) => { @@ -736,7 +941,7 @@ export class MorphoBlue implements BlueActions { shares, receiver, minSharePrice, - reallocations, + reallocations: reallocationList, authorizationSignature: authorization, }, metadata: this.client.options.metadata, @@ -812,19 +1017,21 @@ export class MorphoBlue implements BlueActions { userAddress: Address; positionData: AccrualPosition; slippageTolerance?: bigint; - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; }) { validateChainId(this.client.viemClient.chain?.id, this.chainId); + const reallocationPlan = validateAndNormalizeReallocations({ + reallocations, + targetMarketId: this.marketParams.id, + chainId: this.chainId, + }); + const reallocationList = reallocationPlan.reallocations; if (amount <= 0n) { throw new NonPositiveInputError("amount", amount); } validateSlippageTolerance(slippageTolerance); - if (reallocations) { - validateReallocations(reallocations, this.marketParams.id); - } - if (!positionData) { throw new MissingAccrualPositionError(this.marketParams.id); } @@ -850,13 +1057,19 @@ export class MorphoBlue implements BlueActions { return { getRequirements: async () => { - const authTx = await getBlueAuthorizationRequirement({ - viemClient: this.client.viemClient, - chainId: this.chainId, - userAddress, - supportSignature: this.client.options.supportSignature, - }); - return authTx ? [authTx] : []; + const [penaltyRequirements, authTx] = await Promise.all([ + this.getReallocationPenaltyRequirements( + userAddress, + reallocationPlan, + ), + getBlueAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + userAddress, + supportSignature: this.client.options.supportSignature, + }), + ]); + return [...penaltyRequirements, ...(authTx ? [authTx] : [])]; }, buildTx: (signatures?: readonly RequirementSignature[]) => { @@ -873,7 +1086,7 @@ export class MorphoBlue implements BlueActions { amount, receiver: userAddress, minSharePrice, - reallocations, + reallocations: reallocationList, authorizationSignature: authorization, }, metadata: this.client.options.metadata, @@ -1306,9 +1519,15 @@ export class MorphoBlue implements BlueActions { positionData: AccrualPosition; borrowAmount: bigint; slippageTolerance?: bigint; - reallocations?: readonly VaultReallocation[]; + reallocations?: BlueReallocationPlan; } & DepositAmountArgs) { validateChainId(this.client.viemClient.chain?.id, this.chainId); + const reallocationPlan = validateAndNormalizeReallocations({ + reallocations, + targetMarketId: this.marketParams.id, + chainId: this.chainId, + }); + const reallocationList = reallocationPlan.reallocations; if (amount < 0n) { throw new NegativeInputError("amount", amount); @@ -1328,10 +1547,6 @@ export class MorphoBlue implements BlueActions { } validateSlippageTolerance(slippageTolerance); - if (reallocations) { - validateReallocations(reallocations, this.marketParams.id); - } - if (!positionData) { throw new MissingAccrualPositionError(this.marketParams.id); } @@ -1361,24 +1576,54 @@ export class MorphoBlue implements BlueActions { }); return { getRequirements: async (params?: { useSimplePermit?: boolean }) => { - const [erc20Requirements, authTx] = await Promise.all([ - getGeneralAdapterRequirements(this.client.viemClient, { - address: this.marketParams.collateralToken, - chainId: this.chainId, - supportSignature: this.client.options.supportSignature, - supportDeployless: this.client.options.supportDeployless, - useSimplePermit: params?.useSimplePermit, - args: { amount, from: userAddress }, - }), - getBlueAuthorizationRequirement({ - viemClient: this.client.viemClient, - chainId: this.chainId, - userAddress, - supportSignature: this.client.options.supportSignature, - }), - ]); - - return [...erc20Requirements, ...(authTx ? [authTx] : [])]; + const penaltyAssets = + reallocationPlan.type === "vaultV2Blue" + ? reallocationPlan.reallocations.reduce( + (total, reallocation) => + total + + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + reallocation, + reallocation.assets, + ), + 0n, + ) + : 0n; + const usesSharedFundingToken = isAddressEqual( + this.marketParams.collateralToken, + this.marketParams.loanToken, + ); + const [erc20Requirements, penaltyRequirements, authTx] = + await Promise.all([ + getGeneralAdapterRequirements(this.client.viemClient, { + address: this.marketParams.collateralToken, + chainId: this.chainId, + supportSignature: this.client.options.supportSignature, + supportDeployless: this.client.options.supportDeployless, + useSimplePermit: params?.useSimplePermit, + args: { + amount: amount + (usesSharedFundingToken ? penaltyAssets : 0n), + from: userAddress, + }, + }), + usesSharedFundingToken + ? [] + : this.getReallocationPenaltyRequirements( + userAddress, + reallocationPlan, + ), + getBlueAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + userAddress, + supportSignature: this.client.options.supportSignature, + }), + ]); + + return [ + ...erc20Requirements, + ...penaltyRequirements, + ...(authTx ? [authTx] : []), + ]; }, buildTx: (signatures?: readonly RequirementSignature[]) => { @@ -1401,7 +1646,7 @@ export class MorphoBlue implements BlueActions { minSharePrice, requirementSignature: permit, authorizationSignature: authorization, - reallocations, + reallocations: reallocationList, }, metadata: this.client.options.metadata, }); @@ -1429,10 +1674,16 @@ export class MorphoBlue implements BlueActions { borrowAssets?: bigint; borrowShares?: bigint; slippageTolerance?: bigint; - targetReallocations?: readonly VaultReallocation[]; + targetReallocations?: BlueReallocationPlan; }) { validateChainId(this.client.viemClient.chain?.id, this.chainId); validateSlippageTolerance(slippageTolerance); + const targetReallocationPlan = validateAndNormalizeReallocations({ + reallocations: targetReallocations, + targetMarketId: target.marketParams.id, + chainId: this.chainId, + }); + const targetReallocationList = targetReallocationPlan.reallocations; if (collateralAmount <= 0n) { throw new NonPositiveInputError("collateralAmount", collateralAmount); @@ -1449,10 +1700,6 @@ export class MorphoBlue implements BlueActions { if (requestedAssets > 0n && requestedShares > 0n) { throw new BorrowAmountAndSharesExclusiveError(this.marketParams.id); } - if (targetReallocations) { - validateReallocations(targetReallocations, target.marketParams.id); - } - if (!positionData) { throw new MissingAccrualPositionError(this.marketParams.id); } @@ -1607,13 +1854,19 @@ export class MorphoBlue implements BlueActions { return { getRequirements: async () => { - const authTx = await getBlueAuthorizationRequirement({ - viemClient: this.client.viemClient, - chainId: this.chainId, - userAddress, - supportSignature: this.client.options.supportSignature, - }); - return authTx ? [authTx] : []; + const [penaltyRequirements, authTx] = await Promise.all([ + this.getReallocationPenaltyRequirements( + userAddress, + targetReallocationPlan, + ), + getBlueAuthorizationRequirement({ + viemClient: this.client.viemClient, + chainId: this.chainId, + userAddress, + supportSignature: this.client.options.supportSignature, + }), + ]); + return [...penaltyRequirements, ...(authTx ? [authTx] : [])]; }, buildTx: (signatures?: readonly RequirementSignature[]) => { @@ -1634,7 +1887,7 @@ export class MorphoBlue implements BlueActions { borrowShares: requestedShares, minBorrowSharePrice, maxRepaySharePrice, - targetReallocations, + targetReallocations: targetReallocationList, authorizationSignature: authorization, }, metadata: this.client.options.metadata, @@ -1646,13 +1899,30 @@ export class MorphoBlue implements BlueActions { /** * Fetches all on-chain inputs needed to compute public allocator reallocations. * - * @param params - Reallocation data fetch parameters. * @param params.vaultAddresses - Vaults to inspect for source-market liquidity. - * @param params.block - Block number and timestamp used for consistent RPC reads. - * @returns Reallocation data ready for {@link getReallocations}. + * @param params.block.number - Block number used for every RPC read. + * @param params.block.timestamp - Timestamp corresponding to the fetched block. + * @returns Reallocation data ready for {@link getVaultV1Reallocations}. * @throws {ChainIdMismatchError} when the client chain does not match this market. + * @example + * ```ts + * import { markets, vaults } from "@morpho-org/morpho-test"; + * import { createPublicClient, http } from "viem"; + * import { mainnet } from "viem/chains"; + * import { morphoViemExtension } from "@morpho-org/morpho-sdk"; + * import type { VaultV1ReallocationData } from "@morpho-org/morpho-sdk/entities"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }) + * .extend(morphoViemExtension()); + * const market = client.morpho.blue(markets[mainnet.id].usdc_wbtc, mainnet.id); + * const block = await client.getBlock(); + * const data: VaultV1ReallocationData = await market.getVaultV1ReallocationData({ + * vaultAddresses: [vaults[mainnet.id].steakUsdc.address], + * block, + * }); + * ``` */ - async getReallocationData({ + async getVaultV1ReallocationData({ vaultAddresses, block, }: { @@ -1661,7 +1931,7 @@ export class MorphoBlue implements BlueActions { readonly number: bigint; readonly timestamp: bigint; }; - }): Promise { + }): Promise { validateChainId(this.client.viemClient.chain?.id, this.chainId); const client = this.client.viemClient; @@ -1725,7 +1995,7 @@ export class MorphoBlue implements BlueActions { ), ]); - // Assemble records for ReallocationData. + // Assemble records for VaultV1ReallocationData. const marketsRecord: Record = { [targetMarketId]: targetMarket, }; @@ -1754,7 +2024,7 @@ export class MorphoBlue implements BlueActions { (positionsRecord[vault] ??= {})[mid] = position; } - return new ReallocationData({ + return new VaultV1ReallocationData({ chainId: this.chainId, markets: marketsRecord, vaults: vaultsRecord, @@ -1764,13 +2034,138 @@ export class MorphoBlue implements BlueActions { } /** - * Computes public allocator reallocations for a borrow or withdraw on this market. + * Fetches Vault V1 PublicAllocator state using the deprecated unversioned name. + * + * @param params.vaultAddresses - Addresses of MetaMorpho vaults that allocate to this market. + * @param params.block.number - Block number used for every RPC read. + * @param params.block.timestamp - Timestamp corresponding to the fetched block. + * @returns A `VaultV1ReallocationData` snapshot populated from one block. + * @throws {ChainIdMismatchError} when the client chain does not match this market. + * @deprecated Use {@link getVaultV1ReallocationData} instead. + * @example + * ```ts + * const data = await market.getReallocationData({ vaultAddresses, block }); + * // Equivalent to market.getVaultV1ReallocationData({ vaultAddresses, block }). + * ``` + */ + getReallocationData(params: { + vaultAddresses: readonly Address[]; + block: { + readonly number: bigint; + readonly timestamp: bigint; + }; + }): Promise { + return this.getVaultV1ReallocationData(params); + } + + /** + * Fetches Vault V2 BluePublicAllocator state for this target market. + * + * Reads the target Morpho Blue market, each Vault V2 accrual tree, and each + * vault's BluePublicAllocator permissions and allocation caps at one block. + * + * @param params.vaultAddresses - Vault V2 addresses to inspect for market or idle liquidity. + * @param params.block.number - Block number used for every RPC read. + * @param params.block.timestamp - Timestamp corresponding to the fetched block. + * @returns A `VaultV2BlueReallocationData` snapshot ready for {@link getVaultV2BlueReallocations}. + * @throws {ChainIdMismatchError} when the client chain does not match this market. + * @example + * ```ts + * import { markets } from "@morpho-org/morpho-test"; + * import { createPublicClient, http } from "viem"; + * import { mainnet } from "viem/chains"; + * import { morphoViemExtension } from "@morpho-org/morpho-sdk"; + * import type { VaultV2BlueReallocationData } from "@morpho-org/morpho-sdk/entities"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }) + * .extend(morphoViemExtension()); + * const market = client.morpho.blue(markets[mainnet.id].usdc_wbtc, mainnet.id); + * const block = await client.getBlock(); + * const keyrockUsdcVaultV2 = "0xfDE48B9B8568189f629Bc5209bf5FA826336557a"; + * const data: VaultV2BlueReallocationData = + * await market.getVaultV2BlueReallocationData({ + * vaultAddresses: [keyrockUsdcVaultV2], + * block, + * }); + * ``` + */ + async getVaultV2BlueReallocationData({ + vaultAddresses, + block, + }: { + vaultAddresses: readonly Address[]; + block: { + readonly number: bigint; + readonly timestamp: bigint; + }; + }): Promise { + validateChainId(this.client.viemClient.chain?.id, this.chainId); + + const client = this.client.viemClient; + const fetchParams = { + blockNumber: block.number, + chainId: this.chainId, + deployless: this.client.options.supportDeployless, + }; + const [targetMarket, vaultEntries] = await Promise.all([ + fetchMarket(this.marketParams.id, client, fetchParams), + Promise.all( + vaultAddresses.map(async (address) => { + const vault = await fetchAccrualVaultV2(address, client, fetchParams); + const publicAllocatorData = await fetchVaultV2BluePublicAllocatorData( + vault, + client, + { + ...fetchParams, + targetMarketParams: this.marketParams, + }, + ); + return { publicAllocatorData, vault }; + }), + ), + ]); + + return new VaultV2BlueReallocationData({ + chainId: this.chainId, + markets: { [targetMarket.id]: targetMarket }, + vaults: Object.fromEntries( + vaultEntries.map(({ vault }) => [vault.address, vault]), + ), + allocations: Object.fromEntries( + vaultEntries.map(({ publicAllocatorData, vault }) => [ + vault.address, + publicAllocatorData.allocations, + ]), + ), + publicAllocatorConfigs: Object.fromEntries( + vaultEntries.map(({ publicAllocatorData, vault }) => [ + vault.address, + publicAllocatorData.publicAllocatorConfig, + ]), + ), + activeAdapters: Object.fromEntries( + vaultEntries.map(({ publicAllocatorData, vault }) => [ + vault.address, + publicAllocatorData.activeAdapters, + ]), + ), + marketPublicAllocatorConfigs: Object.fromEntries( + vaultEntries.map(({ publicAllocatorData, vault }) => [ + vault.address, + publicAllocatorData.marketPublicAllocatorConfigs, + ]), + ), + }); + } + + /** + * Computes Vault V1 PublicAllocator reallocations for this market. * * Pass `{ borrowAmount }` for a borrow (legacy alias, equivalent to `{ operation: "borrow", amount }`) * or `{ operation, amount }` for a borrow or loan-asset withdraw. * * @param params - Reallocation computation parameters. - * @param params.reallocationData - State returned by {@link getReallocationData}. + * @param params.reallocationData - State returned by {@link getVaultV1ReallocationData}. * @param params.operation - The operation driving the reallocation (`"borrow"` or `"withdraw"`). * @param params.amount - The borrow or withdraw amount used to compute the post-state utilization. * @param params.borrowAmount - {@deprecated Pass `{ operation: "borrow", amount }` instead.} @@ -1781,32 +2176,25 @@ export class MorphoBlue implements BlueActions { * @throws {ReallocationWithdrawExceedsMarketSupplyError} when `operation === "withdraw"` and `amount` exceeds the target market's `totalSupplyAssets`. * @throws {MissingPublicAllocatorConfigError} when a selected vault is missing its public allocator config. * @throws {UnknownReallocationMarketError} when the target market is absent from the reallocation data. + * @example + * ```ts + * const reallocations = market.getVaultV1Reallocations({ + * reallocationData, + * operation: "borrow", + * amount: 1_000_000n, + * }); + * ``` */ - getReallocations( - params: { - reallocationData: ReallocationData; - options?: ReallocationComputeOptions; - } & ( - | { - operation: "borrow" | "withdraw"; - amount: bigint; - borrowAmount?: never; - } - | { - /** @deprecated Pass `{ operation: "borrow", amount }` instead. */ - borrowAmount: bigint; - operation?: never; - amount?: never; - } - ), - ): readonly VaultReallocation[] { + getVaultV1Reallocations( + params: VaultV1ReallocationsParams, + ): readonly VaultV1Reallocation[] { validateChainId(params.reallocationData.chainId, this.chainId); const marketId = this.marketParams.id; const options = { enabled: true, ...params.options }; if (params.borrowAmount !== undefined) { - return computeReallocations({ + return computeVaultV1Reallocations({ reallocationData: params.reallocationData, marketId, operation: "borrow", @@ -1815,7 +2203,7 @@ export class MorphoBlue implements BlueActions { }); } - return computeReallocations({ + return computeVaultV1Reallocations({ reallocationData: params.reallocationData, marketId, operation: params.operation, @@ -1823,4 +2211,73 @@ export class MorphoBlue implements BlueActions { options, }); } + + /** + * Computes Vault V1 PublicAllocator reallocations using the deprecated unversioned name. + * + * @param params.reallocationData - State returned by {@link getVaultV1ReallocationData}. + * @param params.operation - The operation driving the reallocation (`"borrow"` or `"withdraw"`). + * @param params.amount - The borrow or withdraw amount used to compute post-state utilization. + * @param params.borrowAmount - Deprecated borrow amount alias. + * @param params.options - Optional allocator and utilization options. + * @returns Vault V1 reallocations ready for a Blue action. + * @throws {ChainIdMismatchError} when `reallocationData` belongs to another chain. + * @throws {InsufficientSharedLiquidityError} when shared liquidity cannot cover the operation. + * @throws {ReallocationWithdrawExceedsMarketSupplyError} when a withdrawal exceeds market supply. + * @throws {MissingPublicAllocatorConfigError} when a selected vault lacks allocator state. + * @throws {UnknownReallocationMarketError} when the target market is absent. + * @deprecated Use {@link getVaultV1Reallocations} instead. + * @example + * ```ts + * const reallocations = market.getReallocations({ + * reallocationData, + * operation: "borrow", + * amount: 1_000_000n, + * }); + * ``` + */ + getReallocations( + params: VaultV1ReallocationsParams, + ): readonly VaultV1Reallocation[] { + return this.getVaultV1Reallocations(params); + } + + /** + * Computes Vault V2 BluePublicAllocator reallocations for this market. + * + * @param params.reallocationData - State returned by {@link getVaultV2BlueReallocationData}. + * @param params.options - Optional allocator discovery controls and operation to support. + * @returns Action-ready reallocations and their post-simulation state. + * @throws {ChainIdMismatchError} when `reallocationData` belongs to another chain. + * @throws {NegativeInputError} when a utilization or penalty limit is negative. + * @throws {InputExceedsMaxError} when a utilization or penalty limit exceeds WAD. + * @throws {NonPositiveInputError} when an enabled operation amount is not positive. + * @throws {UnknownReallocationMarketError} when a required market is absent. + * @throws {UnknownReallocationVaultError} when configured vault state is absent. + * @throws {UnknownReallocationPublicAllocatorConfigError} when allocator authorization state is absent. + * @throws {UnknownReallocationActiveAdaptersError} when active-adapter state is absent. + * @throws {InsufficientSharedLiquidityError} when selected liquidity cannot cover the shortfall. + * @throws {ReallocationWithdrawExceedsMarketSupplyError} when a withdrawal exceeds market supply. + * @example + * ```ts + * const result = market.getVaultV2BlueReallocations({ + * reallocationData, + * options: { operation: { type: "borrow", amount: 1_000_000n } }, + * }); + * ``` + */ + getVaultV2BlueReallocations({ + reallocationData, + options, + }: VaultV2BlueReallocationsParams): { + readonly reallocations: readonly VaultV2BlueReallocation[]; + readonly data: VaultV2BlueReallocationData; + } { + validateChainId(reallocationData.chainId, this.chainId); + + return reallocationData.computeVaultV2BlueReallocations( + this.marketParams.id, + options, + ); + } } diff --git a/packages/morpho-sdk/src/entities/index.ts b/packages/morpho-sdk/src/entities/index.ts index 2842fba0b..4929ae20a 100644 --- a/packages/morpho-sdk/src/entities/index.ts +++ b/packages/morpho-sdk/src/entities/index.ts @@ -67,9 +67,15 @@ export { } from "@morpho-org/blue-sdk"; export { MorphoBlue } from "./blue/index.js"; export * from "./midnight/index.js"; +export { MorphoVaultV1 } from "./vaultV1/index.js"; export { type InputReallocationData, + type InputVaultV1ReallocationData, ReallocationData, -} from "./reallocationData.js"; -export { MorphoVaultV1 } from "./vaultV1/index.js"; + VaultV1ReallocationData, +} from "./vaultV1ReallocationData.js"; export { MorphoVaultV2 } from "./vaultV2/index.js"; +export { + type InputVaultV2BlueReallocationData, + VaultV2BlueReallocationData, +} from "./vaultV2BlueReallocationData.js"; diff --git a/packages/morpho-sdk/src/entities/reallocationData.metrics.test.ts b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.metrics.test.ts similarity index 94% rename from packages/morpho-sdk/src/entities/reallocationData.metrics.test.ts rename to packages/morpho-sdk/src/entities/vaultV1ReallocationData.metrics.test.ts index 4506378e9..e582be834 100644 --- a/packages/morpho-sdk/src/entities/reallocationData.metrics.test.ts +++ b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.metrics.test.ts @@ -8,7 +8,7 @@ import { type PublicReallocation, UnknownReallocationMarketError, } from "../types/index.js"; -import { ReallocationData } from "./reallocationData.js"; +import { VaultV1ReallocationData } from "./vaultV1ReallocationData.js"; // --- Constants --- @@ -37,25 +37,25 @@ function makeMarket(overrides?: { }); } -/** Real ReallocationData holding only the target market. */ +/** Real VaultV1ReallocationData holding only the target market. */ function makeData(targetMarket = makeMarket()) { - return new ReallocationData({ + return new VaultV1ReallocationData({ chainId: 1, markets: { [targetMarket.id]: targetMarket }, }); } /** - * Stubs `getMarketPublicReallocations` so the metric methods can be unit-tested + * Stubs `computeVaultV1Reallocations` so the metric methods can be unit-tested * in isolation. The discovery algorithm itself is covered by * `reallocationData.test.ts`. The stub mimics the `enabled: false` short-circuit. */ function stubReallocations( - data: ReallocationData, + data: VaultV1ReallocationData, withdrawals: readonly PublicReallocation[], ) { return vi - .spyOn(data, "getMarketPublicReallocations") + .spyOn(data, "computeVaultV1Reallocations") .mockImplementation((_marketId, options?: PublicAllocatorOptions) => ({ withdrawals: options?.enabled === false ? [] : withdrawals, data, @@ -64,7 +64,7 @@ function stubReallocations( // --------------------------------------------------------------------------- -describe("ReallocationData.getPublicReallocationLiquidity", () => { +describe("VaultV1ReallocationData.getPublicReallocationLiquidity", () => { test("default: sums reallocatable withdrawals", () => { const data = makeData(); stubReallocations(data, [ @@ -103,7 +103,7 @@ describe("ReallocationData.getPublicReallocationLiquidity", () => { }); }); -describe("ReallocationData.getAvailableLiquidityToUtilization", () => { +describe("VaultV1ReallocationData.getAvailableLiquidityToUtilization", () => { test("default: own headroom + scaled available liquidity", () => { // 1000 supply / 500 borrow (50% util). ownHeadroom to 90% = 1000·0.9 − 500 = 400. // supplyTarget set to 90% (not > target) → scaled liquidity 0.9·200 = 180 is diff --git a/packages/morpho-sdk/test/reallocationData/publicAllocator.test.ts b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.publicAllocator.test.ts similarity index 97% rename from packages/morpho-sdk/test/reallocationData/publicAllocator.test.ts rename to packages/morpho-sdk/src/entities/vaultV1ReallocationData.publicAllocator.test.ts index b30042be9..c2394e092 100644 --- a/packages/morpho-sdk/test/reallocationData/publicAllocator.test.ts +++ b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.publicAllocator.test.ts @@ -12,9 +12,9 @@ import { import { type Address, parseEther, parseUnits, zeroAddress } from "viem"; import { describe, expect, test } from "vitest"; import { - type InputReallocationData, - ReallocationData, -} from "../../src/entities/reallocationData.js"; + type InputVaultV1ReallocationData, + VaultV1ReallocationData, +} from "./vaultV1ReallocationData.js"; const timestamp = 12345n; @@ -199,7 +199,7 @@ const makeConfig = ({ }); const makeFixture = () => - new ReallocationData({ + new VaultV1ReallocationData({ chainId: ChainId.EthMainnet, markets: { [marketA1.id]: marketA1, @@ -287,12 +287,12 @@ const makeFixture = () => }), }, }, - } satisfies InputReallocationData); + } satisfies InputVaultV1ReallocationData); -const liquidity = (data: ReallocationData, marketId: MarketId) => +const liquidity = (data: VaultV1ReallocationData, marketId: MarketId) => data.getMarket(marketId).liquidity; -describe("ReallocationData public allocator integration", () => { +describe("VaultV1ReallocationData public allocator integration", () => { test.each([ { marketId: marketA1.id, @@ -385,7 +385,7 @@ describe("ReallocationData public allocator integration", () => { fee: 0n, price: parseUnits("3", 18), }); - const fixture = new ReallocationData({ + const fixture = new VaultV1ReallocationData({ chainId: ChainId.EthMainnet, markets: { [idleMarket.id]: idleMarket, diff --git a/packages/morpho-sdk/src/entities/reallocationData.test.ts b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.test.ts similarity index 91% rename from packages/morpho-sdk/src/entities/reallocationData.test.ts rename to packages/morpho-sdk/src/entities/vaultV1ReallocationData.test.ts index acf133906..ae168e027 100644 --- a/packages/morpho-sdk/src/entities/reallocationData.test.ts +++ b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.test.ts @@ -26,7 +26,11 @@ import { UnknownReallocationVaultError, UnknownReallocationVaultMarketConfigError, } from "../types/index.js"; -import { ReallocationData } from "./reallocationData.js"; +import { + type InputReallocationData, + ReallocationData, + VaultV1ReallocationData, +} from "./vaultV1ReallocationData.js"; const TIMESTAMP = 1_700_000_000n; const VAULT: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; @@ -239,14 +243,14 @@ type ApplyPublicReallocationParams = { readonly timestamp: bigint; }; -class TestReallocationData extends ReallocationData { +class TestReallocationData extends VaultV1ReallocationData { public applyPublicReallocationForTest(params: ApplyPublicReallocationParams) { return this.applyPublicReallocation(params); } } const applyPublicReallocation = ( - data: ReallocationData, + data: VaultV1ReallocationData, withdrawal: PublicReallocation, ) => new TestReallocationData(data).applyPublicReallocationForTest({ @@ -256,7 +260,36 @@ const applyPublicReallocation = ( timestamp: TIMESTAMP, }); -describe("ReallocationData unit coverage", () => { +describe("VaultV1ReallocationData unit coverage", () => { + test("behavior: preserves the deprecated Vault V1 class and input aliases", () => { + const input = { + chainId: ChainId.EthMainnet, + } satisfies InputReallocationData; + + expect(ReallocationData).toBe(VaultV1ReallocationData); + expect(new ReallocationData(input)).toBeInstanceOf(VaultV1ReallocationData); + }); + + test("computeVaultV1Reallocations preserves the deprecated alias behavior", () => { + const input = { + targetSupply: 1_000n * MathLib.WAD, + targetBorrow: 500n * MathLib.WAD, + sourceSupply: 1_000n * MathLib.WAD, + sourceBorrow: 500n * MathLib.WAD, + }; + const canonical = new VaultV1ReallocationData( + makeInput(input), + ).computeVaultV1Reallocations(targetParams.id, { timestamp: TIMESTAMP }); + const deprecated = new VaultV1ReallocationData( + makeInput(input), + ).getMarketPublicReallocations(targetParams.id, { timestamp: TIMESTAMP }); + + expect(deprecated.withdrawals).toStrictEqual(canonical.withdrawals); + expect(deprecated.data.markets).toStrictEqual(canonical.data.markets); + expect(deprecated.data.positions).toStrictEqual(canonical.data.positions); + expect(deprecated.data.vaults).toStrictEqual(canonical.data.vaults); + }); + test("preserves documented entity fields when cloning inputs", () => { const eip5267Domain = new Eip5267Domain({ fields: "0x1f", @@ -333,7 +366,7 @@ describe("ReallocationData unit coverage", () => { const position = new Position(positionInput); const vault = new Vault(vaultInput); const vaultMarketConfig = new VaultMarketConfig(vaultMarketConfigInput); - const data = new ReallocationData({ + const data = new VaultV1ReallocationData({ chainId: ChainId.EthMainnet, markets: { [targetParams.id]: market }, vaults: { [VAULT]: vault }, @@ -374,7 +407,7 @@ describe("ReallocationData unit coverage", () => { }); test("returns empty reallocations when disabled without reading missing target market", () => { - const data = new ReallocationData({ chainId: ChainId.EthMainnet }); + const data = new VaultV1ReallocationData({ chainId: ChainId.EthMainnet }); const missingMarket = `0x${"55".repeat(32)}` as MarketId; expect( @@ -383,7 +416,9 @@ describe("ReallocationData unit coverage", () => { }); test("clones inputs and exposes getters without sharing mutable entity instances", () => { - const emptyData = new ReallocationData({ chainId: ChainId.EthMainnet }); + const emptyData = new VaultV1ReallocationData({ + chainId: ChainId.EthMainnet, + }); expect(emptyData.markets).toEqual({}); expect(emptyData.vaults).toEqual({}); expect(emptyData.positions).toEqual({}); @@ -395,7 +430,7 @@ describe("ReallocationData unit coverage", () => { sourceSupply: 1000n * MathLib.WAD, sourceBorrow: 500n * MathLib.WAD, }); - const data = new ReallocationData({ + const data = new VaultV1ReallocationData({ ...input, markets: { ...input.markets, ["0x00" as MarketId]: undefined }, vaults: { ...input.vaults, [zeroAddress]: undefined }, @@ -465,7 +500,7 @@ describe("ReallocationData unit coverage", () => { maxIn: 0n, maxOut: 10n * MathLib.WAD, }); - const data = new ReallocationData(input); + const data = new VaultV1ReallocationData(input); expect( data.getMarketPublicReallocations(targetParams.id, { enabled: false }), @@ -478,7 +513,7 @@ describe("ReallocationData unit coverage", () => { ).toEqual([]); expect( data.getMarketPublicReallocations(targetParams.id, { - reallocatableVaults: [zeroAddress], + reallocatableVaults: new Set([zeroAddress]), }).withdrawals, ).toEqual([]); @@ -589,7 +624,7 @@ describe("ReallocationData unit coverage", () => { }), }; - const reallocationResult = new ReallocationData( + const reallocationResult = new VaultV1ReallocationData( input, ).getMarketPublicReallocations(targetParams.id, { timestamp: TIMESTAMP, @@ -622,7 +657,7 @@ describe("ReallocationData unit coverage", () => { ).toBe(13n); expect( - new ReallocationData({ + new VaultV1ReallocationData({ ...input, vaultMarketConfigs: { [VAULT]: { @@ -642,7 +677,7 @@ describe("ReallocationData unit coverage", () => { ).toEqual([]); expect( - new ReallocationData({ + new VaultV1ReallocationData({ ...input, vaultMarketConfigs: { [VAULT]: { @@ -669,7 +704,7 @@ describe("ReallocationData unit coverage", () => { ]); expect( - new ReallocationData({ + new VaultV1ReallocationData({ ...input, vaultMarketConfigs: { [VAULT]: { @@ -711,7 +746,7 @@ describe("ReallocationData unit coverage", () => { expect(() => applyPublicReallocation( - new ReallocationData({ + new VaultV1ReallocationData({ ...baseInput, vaults: { [VAULT]: makeVault({ withoutPublicAllocatorConfig: true }), @@ -723,7 +758,7 @@ describe("ReallocationData unit coverage", () => { expect(() => applyPublicReallocation( - new ReallocationData({ + new VaultV1ReallocationData({ ...baseInput, vaultMarketConfigs: { [VAULT]: { @@ -744,7 +779,7 @@ describe("ReallocationData unit coverage", () => { expect(() => applyPublicReallocation( - new ReallocationData({ + new VaultV1ReallocationData({ ...baseInput, vaultMarketConfigs: { [VAULT]: { @@ -765,7 +800,7 @@ describe("ReallocationData unit coverage", () => { expect(() => applyPublicReallocation( - new ReallocationData({ + new VaultV1ReallocationData({ ...baseInput, vaultMarketConfigs: { [VAULT]: { @@ -786,7 +821,7 @@ describe("ReallocationData unit coverage", () => { expect(() => applyPublicReallocation( - new ReallocationData({ + new VaultV1ReallocationData({ ...baseInput, vaultMarketConfigs: { [VAULT]: { @@ -806,7 +841,7 @@ describe("ReallocationData unit coverage", () => { ).toThrow(DisabledReallocationMarketError); const sameMarketData = new TestReallocationData( - new ReallocationData(baseInput), + new VaultV1ReallocationData(baseInput), ).applyPublicReallocationForTest({ vault: VAULT, supplyMarketId: sourceParams.id, diff --git a/packages/morpho-sdk/src/entities/reallocationData.ts b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.ts similarity index 92% rename from packages/morpho-sdk/src/entities/reallocationData.ts rename to packages/morpho-sdk/src/entities/vaultV1ReallocationData.ts index fb92da166..e0221bee1 100644 --- a/packages/morpho-sdk/src/entities/reallocationData.ts +++ b/packages/morpho-sdk/src/entities/vaultV1ReallocationData.ts @@ -32,9 +32,9 @@ import { } from "../types/index.js"; /** - * Input state required to construct {@link ReallocationData}. + * Input state required to construct {@link VaultV1ReallocationData}. */ -export interface InputReallocationData { +export interface InputVaultV1ReallocationData { /** Chain id associated with the fetched state. */ readonly chainId: number; @@ -55,6 +55,13 @@ export interface InputReallocationData { >; } +/** + * Deprecated input name for Vault V1 reallocation data. + * + * @deprecated Use {@link InputVaultV1ReallocationData} instead. + */ +export type InputReallocationData = InputVaultV1ReallocationData; + /** * Clones a market so simulated interest and liquidity changes never mutate caller input. * @@ -110,7 +117,7 @@ const cloneVaultMarketConfig = (config: VaultMarketConfig) => * @remarks * The class owns only the market, vault, position, vault-market-config, * and chain data needed by the shared-liquidity algorithm. Constructor inputs - * are cloned, and simulation steps return cloned `ReallocationData` instances + * are cloned, and simulation steps return cloned `VaultV1ReallocationData` instances * so fetched caller inputs are not mutated. * * Public records are exposed for inspection and snapshotting only. Treat @@ -118,7 +125,7 @@ const cloneVaultMarketConfig = (config: VaultMarketConfig) => * contract keyed by market id or address; use the getters for typed absence * errors and use simulation methods to produce updated state. */ -export class ReallocationData implements InputReallocationData { +export class VaultV1ReallocationData implements InputVaultV1ReallocationData { /** Chain id associated with the fetched reallocation data. */ public readonly chainId: number; @@ -146,7 +153,7 @@ export class ReallocationData implements InputReallocationData { * * @param input - Reallocation input data fetched at a consistent chain state. */ - constructor(input: InputReallocationData) { + constructor(input: InputVaultV1ReallocationData) { const { chainId, markets, vaults, positions, vaultMarketConfigs } = input; this.chainId = chainId; @@ -202,14 +209,14 @@ export class ReallocationData implements InputReallocationData { /** * Creates a deep clone of this reallocation state. * - * @returns A new `ReallocationData` instance with cloned entity objects. + * @returns A new `VaultV1ReallocationData` instance with cloned entity objects. */ public clone() { - return new ReallocationData(this); + return new VaultV1ReallocationData(this); } private forkAliasedState() { - const data = new ReallocationData({ chainId: this.chainId }); + const data = new VaultV1ReallocationData({ chainId: this.chainId }); Object.assign(data.markets, this.markets); Object.assign(data.vaults, this.vaults); @@ -343,7 +350,7 @@ export class ReallocationData implements InputReallocationData { * morphoViemExtension, * type PublicReallocation, * } from "@morpho-org/morpho-sdk"; - * import type { ReallocationData } from "@morpho-org/morpho-sdk/entities"; + * import type { VaultV1ReallocationData } from "@morpho-org/morpho-sdk/entities"; * * const client = createPublicClient({ * chain: mainnet, @@ -353,25 +360,25 @@ export class ReallocationData implements InputReallocationData { * const marketParams = markets[mainnet.id].usdc_wbtc; * const market = client.morpho.blue(marketParams, mainnet.id); * const block = await client.getBlock(); - * const reallocationData = await market.getReallocationData({ + * const reallocationData = await market.getVaultV1ReallocationData({ * vaultAddresses: [vaults[mainnet.id].steakUsdc.address], * block: { number: block.number, timestamp: block.timestamp }, * }); * * const result: { * withdrawals: readonly PublicReallocation[]; - * data: ReallocationData; - * } = reallocationData.getMarketPublicReallocations(marketParams.id, { + * data: VaultV1ReallocationData; + * } = reallocationData.computeVaultV1Reallocations(marketParams.id, { * timestamp: block.timestamp, * }); * ``` */ - public getMarketPublicReallocations( + public computeVaultV1Reallocations( marketId: MarketId, options: PublicAllocatorOptions = {}, ): { readonly withdrawals: readonly PublicReallocation[]; - data: ReallocationData; + data: VaultV1ReallocationData; } { const { enabled = true, @@ -393,7 +400,7 @@ export class ReallocationData implements InputReallocationData { ); const vaults = Array.from( new Set( - (reallocatableVaults ?? configuredVaults) + [...(reallocatableVaults ?? configuredVaults)] .map((vault) => vaultKeyByLower.get(vault.toLowerCase())) .filter((vault): vault is Address => vault != null), ), @@ -467,6 +474,22 @@ export class ReallocationData implements InputReallocationData { } } + /** + * Calculates Vault V1 public reallocations that can supply liquidity to `marketId`. + * + * @param marketId - Target market to supply with shared liquidity. + * @param options - Optional allocator discovery options. + * @returns Computed source-market withdrawals and the post-reallocation state. + * @throws {@link UnknownReallocationMarketError} when the target market is absent. + * @deprecated Use {@link computeVaultV1Reallocations} instead. + */ + public getMarketPublicReallocations( + marketId: MarketId, + options: PublicAllocatorOptions = {}, + ) { + return this.computeVaultV1Reallocations(marketId, options); + } + /** * Sums the public-allocator liquidity reallocatable into `marketId` from * sibling markets. @@ -495,7 +518,7 @@ export class ReallocationData implements InputReallocationData { * const marketParams = markets[mainnet.id].usdc_wbtc; * const market = client.morpho.blue(marketParams, mainnet.id); * const block = await client.getBlock(); - * const reallocationData = await market.getReallocationData({ + * const reallocationData = await market.getVaultV1ReallocationData({ * vaultAddresses: [vaults[mainnet.id].steakUsdc.address], * block: { number: block.number, timestamp: block.timestamp }, * }); @@ -510,10 +533,7 @@ export class ReallocationData implements InputReallocationData { marketId: MarketId, options?: PublicAllocatorOptions, ): bigint { - const { withdrawals } = this.getMarketPublicReallocations( - marketId, - options, - ); + const { withdrawals } = this.computeVaultV1Reallocations(marketId, options); return withdrawals.reduce((total, { assets }) => total + assets, 0n); } @@ -557,7 +577,7 @@ export class ReallocationData implements InputReallocationData { * const marketParams = markets[mainnet.id].usdc_wbtc; * const market = client.morpho.blue(marketParams, mainnet.id); * const block = await client.getBlock(); - * const reallocationData = await market.getReallocationData({ + * const reallocationData = await market.getVaultV1ReallocationData({ * vaultAddresses: [vaults[mainnet.id].steakUsdc.address], * block: { number: block.number, timestamp: block.timestamp }, * }); @@ -792,3 +812,10 @@ export class ReallocationData implements InputReallocationData { return data; } } + +/** + * Deprecated class name for Vault V1 reallocation data. + * + * @deprecated Use {@link VaultV1ReallocationData} instead. + */ +export { VaultV1ReallocationData as ReallocationData }; diff --git a/packages/morpho-sdk/src/entities/vaultV2BlueReallocationData.test.ts b/packages/morpho-sdk/src/entities/vaultV2BlueReallocationData.test.ts new file mode 100644 index 000000000..e336c56db --- /dev/null +++ b/packages/morpho-sdk/src/entities/vaultV2BlueReallocationData.test.ts @@ -0,0 +1,1631 @@ +import { + AccrualPosition, + AccrualVault, + AccrualVaultV2, + AccrualVaultV2MorphoMarketV1Adapter, + AccrualVaultV2MorphoMarketV1AdapterV2, + AccrualVaultV2MorphoVaultV1Adapter, + ChainId, + type IAccrualVaultV2Adapter, + type IVaultV2Allocation, + Market, + type MarketId, + MarketParams, + MathLib, + UnsupportedVaultV2AdapterError, + VaultV2BlueMarketPublicAllocatorConfig, + VaultV2BluePublicAllocatorConfig, +} from "@morpho-org/blue-sdk"; +import type { Address, Hash } from "viem"; +import { zeroAddress, zeroHash } from "viem"; +import { describe, expect, test, vi } from "vitest"; +import { blueBorrow } from "../actions/index.js"; +import { MAX_REALLOCATION_PENALTY } from "../helpers/constant.js"; +import { + InputExceedsMaxError, + InsufficientSharedLiquidityError, + NegativeInputError, + NonPositiveInputError, + ReallocationAdapterSupplySharesUnderflowError, + ReallocationAllocationUnderflowError, + ReallocationWithdrawExceedsMarketSupplyError, + UnknownReallocationActiveAdaptersError, + UnknownReallocationAdapterError, + UnknownReallocationAllocationError, + UnknownReallocationMarketError, + UnknownReallocationMarketPublicAllocatorConfigError, + UnknownReallocationPublicAllocatorConfigError, +} from "../types/index.js"; +import { VaultV2BlueReallocationData } from "./vaultV2BlueReallocationData.js"; + +const TIMESTAMP = 1_700_000_000n; +const VAULT = "0x00000000000000000000000000000000000000A2"; +const TARGET_ADAPTER = "0x00000000000000000000000000000000000000A3"; +const SOURCE_ADAPTER = "0x0000000000000000000000000000000000000004"; +const LOAN_TOKEN = "0x0000000000000000000000000000000000000005"; +const IRM = "0x0000000000000000000000000000000000000006"; +const SECOND_VAULT = "0x000000000000000000000000000000000000000b"; +const SECOND_TARGET_ADAPTER = "0x000000000000000000000000000000000000000C"; +const LEGACY_MARKET_ADAPTER = "0x000000000000000000000000000000000000000d"; +const VAULT_V1_ADAPTER = "0x000000000000000000000000000000000000000E"; +const NESTED_VAULT = "0x000000000000000000000000000000000000000F"; + +const targetParams = new MarketParams({ + loanToken: LOAN_TOKEN, + collateralToken: "0x0000000000000000000000000000000000000007", + oracle: "0x0000000000000000000000000000000000000008", + irm: IRM, + lltv: 860_000_000_000_000_000n, +}); + +const sourceParams = new MarketParams({ + loanToken: LOAN_TOKEN, + collateralToken: "0x0000000000000000000000000000000000000009", + oracle: "0x000000000000000000000000000000000000000A", + irm: IRM, + lltv: 860_000_000_000_000_000n, +}); + +const makeMarket = ({ + params, + supply, + supplyShares = supply * 1_000_000n, + borrow, + lastUpdate = TIMESTAMP, +}: { + readonly params: MarketParams; + readonly supply: bigint; + readonly supplyShares?: bigint; + readonly borrow: bigint; + readonly lastUpdate?: bigint; +}) => + new Market({ + params, + totalSupplyAssets: supply, + totalBorrowAssets: borrow, + totalSupplyShares: supplyShares, + totalBorrowShares: borrow * 1_000_000n, + lastUpdate, + fee: 0n, + }); + +interface FixtureOptions { + readonly recordVault?: Address; + readonly sourceMarketParams?: MarketParams; + readonly sourceAdapter?: Address; + readonly sourceSupply?: bigint; + readonly sourceBorrow?: bigint; + readonly sourceUntracked?: bigint; + readonly targetSupply?: bigint; + readonly targetTotalSupplyShares?: bigint; + readonly targetBorrow?: bigint; + readonly targetPositionAssets?: bigint; + readonly targetTracked?: boolean; + readonly targetUntracked?: bigint; + readonly targetCaps?: readonly [ + { readonly absoluteCap: bigint; readonly relativeCap: bigint }, + { readonly absoluteCap: bigint; readonly relativeCap: bigint }, + { readonly absoluteCap: bigint; readonly relativeCap: bigint }, + ]; + readonly allocatorTargetCap?: bigint; + readonly firstTotalAssets?: bigint; + readonly idle?: bigint; + readonly canPullFromIdle?: boolean; + readonly canPullFromMarket?: boolean; + readonly allocatorActiveAdapters?: Iterable
; + readonly penalty?: bigint; + readonly sourceLastUpdate?: bigint; + readonly targetLastUpdate?: bigint; + readonly vaultLastUpdate?: bigint; + readonly maxRate?: bigint; +} + +const makeFixture = ({ + recordVault = VAULT, + sourceMarketParams = sourceParams, + sourceAdapter: sourceAdapterAddress = SOURCE_ADAPTER, + sourceSupply = 1_000n, + sourceBorrow = 0n, + sourceUntracked = 0n, + targetSupply = 100n, + targetTotalSupplyShares, + targetBorrow = 0n, + targetPositionAssets = 0n, + targetTracked = true, + targetUntracked = 0n, + targetCaps = [ + { absoluteCap: 10_000n, relativeCap: MathLib.WAD }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD }, + ], + allocatorTargetCap = 10_000n, + firstTotalAssets, + idle = 0n, + canPullFromIdle = true, + canPullFromMarket = true, + allocatorActiveAdapters, + penalty = 0n, + sourceLastUpdate = TIMESTAMP, + targetLastUpdate = TIMESTAMP, + vaultLastUpdate = TIMESTAMP, + maxRate = 0n, +}: FixtureOptions = {}) => { + const sameMarket = sourceMarketParams.id === targetParams.id; + const targetMarket = makeMarket({ + params: targetParams, + supply: sameMarket ? sourceSupply : targetSupply, + supplyShares: targetTotalSupplyShares, + borrow: sameMarket ? sourceBorrow : targetBorrow, + lastUpdate: targetLastUpdate, + }); + const sourceMarket = sameMarket + ? targetMarket + : makeMarket({ + params: sourceMarketParams, + supply: sourceSupply, + borrow: sourceBorrow, + lastUpdate: sourceLastUpdate, + }); + const targetSupplyShares = targetMarket.toSupplyShares( + targetPositionAssets, + "Down", + ); + const sourceSupplyShares = sourceMarket.toSupplyShares(sourceSupply, "Down"); + const targetExpectedAssets = targetMarket.toSupplyAssets(targetSupplyShares); + const sourceExpectedAssets = sourceMarket.toSupplyAssets(sourceSupplyShares); + + const targetAdapter = new AccrualVaultV2MorphoMarketV1AdapterV2( + { + address: TARGET_ADAPTER, + parentVault: VAULT, + skimRecipient: zeroAddress, + marketIds: targetTracked ? [targetMarket.id] : [], + adaptiveCurveIrm: IRM, + supplyShares: targetTracked + ? { [targetMarket.id]: targetSupplyShares } + : {}, + }, + targetTracked ? [targetMarket] : [], + ); + const sourceAdapter = new AccrualVaultV2MorphoMarketV1AdapterV2( + { + address: sourceAdapterAddress, + parentVault: VAULT, + skimRecipient: zeroAddress, + marketIds: [sourceMarket.id], + adaptiveCurveIrm: IRM, + supplyShares: { [sourceMarket.id]: sourceSupplyShares }, + }, + [sourceMarket], + ); + const targetIds = targetAdapter.ids(targetMarket.params); + const [, , targetAdapterMarketCapId] = targetIds; + const sourceIds = sourceAdapter.ids(sourceMarket.params); + const [sourceAdapterCapId, , sourceAdapterMarketCapId] = sourceIds; + const allocations: Record = {}; + + const addAllocation = ({ + id, + allocation, + cap, + }: { + readonly id: Hash; + readonly allocation: bigint; + readonly cap: { + readonly absoluteCap: bigint; + readonly relativeCap: bigint; + }; + }) => { + const current = allocations[id]; + allocations[id] = { + id, + absoluteCap: cap.absoluteCap, + relativeCap: cap.relativeCap, + allocation: (current?.allocation ?? 0n) + allocation, + }; + }; + + for (const id of sourceIds) { + addAllocation({ + id, + allocation: sourceExpectedAssets - sourceUntracked, + cap: { + absoluteCap: 10_000n, + relativeCap: MathLib.WAD, + }, + }); + } + for (const [index, id] of targetIds.entries()) { + addAllocation({ + id, + allocation: targetExpectedAssets - targetUntracked, + cap: targetCaps[index]!, + }); + } + + const adapters = + sourceAdapterAddress === TARGET_ADAPTER + ? [ + new AccrualVaultV2MorphoMarketV1AdapterV2( + { + address: TARGET_ADAPTER, + parentVault: VAULT, + skimRecipient: zeroAddress, + marketIds: Array.from( + new Set([targetMarket.id, sourceMarket.id]), + ), + adaptiveCurveIrm: IRM, + supplyShares: { + [targetMarket.id]: targetSupplyShares, + [sourceMarket.id]: sourceSupplyShares, + }, + }, + sameMarket ? [targetMarket] : [targetMarket, sourceMarket], + ), + ] + : [targetAdapter, sourceAdapter]; + const totalAssets = + firstTotalAssets ?? sourceExpectedAssets + targetExpectedAssets + idle; + const vault = new AccrualVaultV2( + { + address: VAULT, + name: "Vault V2", + symbol: "v2", + decimals: 18, + asset: LOAN_TOKEN, + _totalAssets: totalAssets, + totalSupply: totalAssets, + virtualShares: 0n, + maxRate, + lastUpdate: vaultLastUpdate, + liquidityAdapter: zeroAddress, + liquidityData: "0x", + liquidityAllocations: undefined, + performanceFee: 0n, + managementFee: 0n, + performanceFeeRecipient: zeroAddress, + managementFeeRecipient: zeroAddress, + }, + undefined, + adapters, + idle, + {}, + ); + + return { + data: new VaultV2BlueReallocationData({ + chainId: ChainId.EthMainnet, + markets: { + [targetMarket.id]: targetMarket, + [sourceMarket.id]: sourceMarket, + }, + vaults: { [VAULT]: vault }, + allocations: { [recordVault]: allocations }, + publicAllocatorConfigs: { + [recordVault]: { + vault: VAULT, + canPullFromIdle, + penalty, + }, + }, + activeAdapters: { + [recordVault]: + allocatorActiveAdapters ?? adapters.map((adapter) => adapter.address), + }, + marketPublicAllocatorConfigs: { + [recordVault]: { + [targetAdapterMarketCapId]: { + vault: VAULT, + adapter: TARGET_ADAPTER, + adapterMarketCapId: targetAdapterMarketCapId, + absoluteCap: allocatorTargetCap, + canPullFromMarket: false, + }, + [sourceAdapterMarketCapId]: { + vault: VAULT, + adapter: sourceAdapterAddress, + adapterMarketCapId: sourceAdapterMarketCapId, + absoluteCap: 0n, + canPullFromMarket, + }, + }, + }, + }), + sourceAdapterCapId, + sourceAdapterMarketCapId, + sourceExpectedAssets, + targetAdapterMarketCapId, + targetExpectedAssets, + }; +}; + +describe("VaultV2BlueReallocationData construction", () => { + test("error: UnsupportedVaultV2AdapterError", () => { + const { data } = makeFixture(); + const vault = data.getVault(VAULT); + const adapter = data.getAdapter(VAULT, TARGET_ADAPTER); + const unsupportedAdapter: IAccrualVaultV2Adapter = { + type: "UnsupportedAdapter", + address: SECOND_TARGET_ADAPTER, + parentVault: VAULT, + adapterId: adapter.adapterId, + skimRecipient: zeroAddress, + realAssets: adapter.realAssets.bind(adapter), + maxDeposit: adapter.maxDeposit.bind(adapter), + maxWithdraw: adapter.maxWithdraw.bind(adapter), + }; + const unsupportedVault = new AccrualVaultV2( + { + ...vault, + liquidityAllocations: vault.liquidityAllocations?.map((allocation) => ({ + ...allocation, + })), + }, + undefined, + [unsupportedAdapter], + vault.assetBalance, + { ...vault.forceDeallocatePenalties }, + ); + + expect( + () => + new VaultV2BlueReallocationData({ + chainId: data.chainId, + vaults: { [VAULT]: unsupportedVault }, + }), + ).toThrow(UnsupportedVaultV2AdapterError); + }); +}); + +describe("VaultV2BlueReallocationData accessors", () => { + test("behavior: preserves checksummed address keys and values", () => { + const { data } = makeFixture(); + + expect(Object.keys(data.vaults)).toStrictEqual([VAULT]); + expect(Object.keys(data.activeAdapters)).toStrictEqual([VAULT]); + expect( + data.getActiveAdapters(VAULT.toLowerCase() as Address), + ).toStrictEqual(new Set([TARGET_ADAPTER, SOURCE_ADAPTER])); + }); + + test("behavior: returns a fetched empty active-adapter set", () => { + const { data } = makeFixture({ allocatorActiveAdapters: [] }); + + expect(data.getActiveAdapters(VAULT)).toStrictEqual(new Set()); + }); + + test("error: UnknownReallocationActiveAdaptersError", () => { + const { data } = makeFixture(); + delete ( + data.activeAdapters as Record | undefined> + )[VAULT]; + + expect(() => data.getActiveAdapters(VAULT)).toThrow( + UnknownReallocationActiveAdaptersError, + ); + }); + + test("error: UnknownReallocationAllocationError", () => { + const { data } = makeFixture(); + + expect(() => data.getAllocation(VAULT, zeroHash)).toThrow( + UnknownReallocationAllocationError, + ); + }); + + test("error: UnknownReallocationPublicAllocatorConfigError", () => { + const { data } = makeFixture(); + + expect(() => data.getPublicAllocatorConfig(SECOND_VAULT)).toThrow( + UnknownReallocationPublicAllocatorConfigError, + ); + }); + + test("error: UnknownReallocationMarketPublicAllocatorConfigError", () => { + const { data } = makeFixture(); + + expect(() => data.getMarketPublicAllocatorConfig(VAULT, zeroHash)).toThrow( + UnknownReallocationMarketPublicAllocatorConfigError, + ); + }); + + test("error: UnknownReallocationAdapterError", () => { + const { data } = makeFixture(); + + expect(() => data.getAdapter(VAULT, zeroAddress)).toThrow( + UnknownReallocationAdapterError, + ); + }); +}); + +describe("VaultV2BlueReallocationData.computeVaultV2BlueReallocations", () => { + test("default: returns an action-ready market reallocation and cloned post-state", () => { + const { + data, + sourceAdapterMarketCapId, + sourceExpectedAssets, + targetAdapterMarketCapId, + } = makeFixture(); + + expect(data.getActiveAdapters(VAULT)).toStrictEqual( + new Set([TARGET_ADAPTER, SOURCE_ADAPTER]), + ); + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect(result.reallocations).toStrictEqual([ + { + vault: VAULT, + from: { + type: "market", + adapter: SOURCE_ADAPTER, + marketParams: sourceParams, + }, + to: { adapter: TARGET_ADAPTER }, + assets: sourceExpectedAssets, + penalty: 0n, + }, + ]); + expect(result.data).not.toBe(data); + expect( + result.data.getAllocation(VAULT, sourceAdapterMarketCapId).allocation, + ).toBe(0n); + expect( + result.data.getAllocation(VAULT, targetAdapterMarketCapId).allocation, + ).toBe(sourceExpectedAssets); + expect(result.data.getVault(VAULT)._totalAssets).toBe( + data.getVault(VAULT)._totalAssets, + ); + }); + + test("behavior: matches differently cased vault record keys", () => { + const recordVault = VAULT.toLowerCase() as Address; + const { data, sourceExpectedAssets } = makeFixture({ recordVault }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect(Object.keys(data.vaults)).toStrictEqual([VAULT]); + expect(Object.keys(data.allocations)).toStrictEqual([recordVault]); + expect(result.reallocations[0]?.assets).toBe(sourceExpectedAssets); + expect(Object.keys(result.data.vaults)).toStrictEqual([VAULT]); + expect(Object.keys(result.data.allocations)).toStrictEqual([recordVault]); + }); + + test("error: ReallocationAdapterSupplySharesUnderflowError", () => { + const { data } = makeFixture(); + const sourceAdapter = data.getAdapter(VAULT, SOURCE_ADAPTER); + (sourceAdapter.supplyShares as Record)[sourceParams.id] = + 0n; + + expect(() => + // biome-ignore lint/complexity/useLiteralKeys: exercise the private transition invariant directly. + data["cloneWithPublicReallocation"]({ + reallocation: { + vault: VAULT, + from: { + type: "market", + adapter: SOURCE_ADAPTER, + marketParams: sourceParams, + }, + to: { adapter: TARGET_ADAPTER }, + assets: 1n, + penalty: 7n, + }, + targetMarketId: targetParams.id, + timestamp: TIMESTAMP, + }), + ).toThrow(ReallocationAdapterSupplySharesUnderflowError); + }); + + test("error: ReallocationAllocationUnderflowError", () => { + const { data, sourceAdapterCapId } = makeFixture(); + const allocation = data.getAllocation(VAULT, sourceAdapterCapId); + (data.allocations[VAULT] as Record)[ + sourceAdapterCapId + ] = { ...allocation, allocation: 0n }; + + expect(() => + // biome-ignore lint/complexity/useLiteralKeys: exercise the private transition invariant directly. + data["cloneWithPublicReallocation"]({ + reallocation: { + vault: VAULT, + from: { + type: "market", + adapter: SOURCE_ADAPTER, + marketParams: sourceParams, + }, + to: { adapter: TARGET_ADAPTER }, + assets: 1n, + penalty: 7n, + }, + targetMarketId: targetParams.id, + timestamp: TIMESTAMP, + }), + ).toThrow(ReallocationAllocationUnderflowError); + }); + + test("behavior: rejects underflowing cap probes without aborting discovery", () => { + const { data, sourceAdapterCapId } = makeFixture(); + const allocation = data.getAllocation(VAULT, sourceAdapterCapId); + (data.allocations[VAULT] as Record)[ + sourceAdapterCapId + ] = { ...allocation, allocation: 1n }; + + expect(data.getPublicReallocationLiquidity(targetParams.id)).toBe(1n); + }); + + test("behavior: allocates into a configured target with no existing position", () => { + const { data, sourceExpectedAssets, targetAdapterMarketCapId } = + makeFixture({ + targetTracked: false, + }); + + expect(data.getAdapter(VAULT, TARGET_ADAPTER).marketIds).not.toContain( + targetParams.id, + ); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + const targetAdapter = result.data.getAdapter(VAULT, TARGET_ADAPTER); + + expect(result.reallocations).toHaveLength(1); + expect(targetAdapter.marketIds).toContain(targetParams.id); + expect(targetAdapter.markets.map(({ id }) => id)).toContain( + targetParams.id, + ); + expect(targetAdapter.supplyShares[targetParams.id]).toBeGreaterThan(0n); + expect( + result.data.getAllocation(VAULT, targetAdapterMarketCapId).allocation, + ).toBe(sourceExpectedAssets); + }); + + test("behavior: honors the configured source-utilization ceiling", () => { + const { data } = makeFixture({ sourceBorrow: 900n }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + expect( + data.computeVaultV2BlueReallocations(targetParams.id, { + maxWithdrawalUtilization: MathLib.WAD, + }).reallocations[0]?.assets, + ).toBe(100n); + }); + + test("behavior: refreshes only adapter views for changed markets", () => { + const { data } = makeFixture(); + const targetAdapter = data.getAdapter(VAULT, TARGET_ADAPTER); + const sourceAdapter = data.getAdapter(VAULT, SOURCE_ADAPTER); + const previousTargetMarkets = targetAdapter.markets; + const previousSourceMarkets = sourceAdapter.markets; + const targetMarket = data + .getMarket(targetParams.id) + .accrueInterest(TIMESTAMP + 1n); + + ( + data as unknown as { + setMarkets(markets: Iterable): void; + } + ).setMarkets([targetMarket]); + + expect(data.getMarket(targetParams.id)).toBe(targetMarket); + expect(targetAdapter.markets).not.toBe(previousTargetMarkets); + expect(targetAdapter.markets[0]).toBe(targetMarket); + expect(sourceAdapter.markets).toBe(previousSourceMarkets); + }); + + test.each([0n, MathLib.WAD])( + "behavior: accepts maxWithdrawalUtilization boundary %s", + (maxWithdrawalUtilization) => { + const { data } = makeFixture(); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + maxWithdrawalUtilization, + }), + ).not.toThrow(); + }, + ); + + test.each([0n, MAX_REALLOCATION_PENALTY])( + "behavior: accepts maxPenalty boundary %s", + (maxPenalty) => { + const { data } = makeFixture(); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { maxPenalty }), + ).not.toThrow(); + }, + ); + + test.each([ + { + maxWithdrawalUtilization: -1n, + ErrorClass: NegativeInputError, + }, + { + maxWithdrawalUtilization: MathLib.WAD + 1n, + ErrorClass: InputExceedsMaxError, + }, + ])( + "error: rejects maxWithdrawalUtilization $maxWithdrawalUtilization", + ({ maxWithdrawalUtilization, ErrorClass }) => { + const { data } = makeFixture(); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + maxWithdrawalUtilization, + }), + ).toThrow(ErrorClass); + }, + ); + + test.each([ + { maxPenalty: -1n, ErrorClass: NegativeInputError }, + { + maxPenalty: MAX_REALLOCATION_PENALTY + 1n, + ErrorClass: InputExceedsMaxError, + }, + ])("error: rejects maxPenalty $maxPenalty", ({ maxPenalty, ErrorClass }) => { + const { data } = makeFixture(); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { maxPenalty }), + ).toThrow(ErrorClass); + }); + + test("behavior: skips targets whose Morpho supply would mint fewer shares than assets", () => { + const { data } = makeFixture({ + targetSupply: 2_000_000n, + targetTotalSupplyShares: 0n, + idle: 300n, + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: ignores inactive source and target adapters", () => { + for (const allocatorActiveAdapters of [ + [TARGET_ADAPTER], + [SOURCE_ADAPTER], + ] as const) { + const { data } = makeFixture({ allocatorActiveAdapters }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + } + }); + + test("behavior: matches active adapters regardless of address casing", () => { + const sourceAdapter = + "0x00000000000000000000000000000000000000AB" as Address; + const { data } = makeFixture({ + sourceAdapter, + allocatorActiveAdapters: [ + `0x${TARGET_ADAPTER.slice(2).toUpperCase()}` as Address, + `0x${sourceAdapter.slice(2).toUpperCase()}` as Address, + ], + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toHaveLength(1); + }); + + test("behavior: keeps two vault adapters on one canonical market", () => { + const { data } = makeFixture({ + sourceSupply: 0n, + targetPositionAssets: 50n, + idle: 500n, + canPullFromMarket: false, + penalty: 0n, + }); + const targetMarket = data.getMarket(targetParams.id); + const secondTargetShares = targetMarket.toSupplyShares(50n, "Down"); + const secondTargetAdapter = new AccrualVaultV2MorphoMarketV1AdapterV2( + { + address: SECOND_TARGET_ADAPTER, + parentVault: SECOND_VAULT, + skimRecipient: zeroAddress, + marketIds: [targetMarket.id], + adaptiveCurveIrm: IRM, + supplyShares: { [targetMarket.id]: secondTargetShares }, + }, + [new Market({ ...targetMarket })], + ); + const secondTargetIds = secondTargetAdapter.ids(targetParams); + const [, , secondTargetAdapterMarketCapId] = secondTargetIds; + const secondAllocations: Record = {}; + for (const id of secondTargetIds) { + secondAllocations[id] = { + id, + absoluteCap: 10_000n, + relativeCap: MathLib.WAD, + allocation: 50n, + }; + } + const firstVault = data.getVault(VAULT); + const secondVault = new AccrualVaultV2( + { + ...firstVault, + address: SECOND_VAULT, + _totalAssets: 550n, + totalSupply: 550n, + liquidityAllocations: firstVault.liquidityAllocations?.map( + (allocation) => ({ ...allocation }), + ), + }, + undefined, + [secondTargetAdapter], + 500n, + {}, + ); + const sharedData = new VaultV2BlueReallocationData({ + chainId: data.chainId, + markets: data.markets, + vaults: { + [VAULT]: firstVault, + [SECOND_VAULT]: secondVault, + }, + allocations: { + [VAULT]: data.allocations[VAULT], + [SECOND_VAULT]: secondAllocations, + }, + publicAllocatorConfigs: { + [VAULT]: data.publicAllocatorConfigs[VAULT], + [SECOND_VAULT]: { + vault: SECOND_VAULT, + canPullFromIdle: true, + penalty: 0n, + }, + }, + activeAdapters: { + [VAULT]: data.activeAdapters[VAULT], + [SECOND_VAULT]: new Set([SECOND_TARGET_ADAPTER]), + }, + marketPublicAllocatorConfigs: { + [VAULT]: data.marketPublicAllocatorConfigs[VAULT], + [SECOND_VAULT]: { + [secondTargetAdapterMarketCapId]: { + vault: SECOND_VAULT, + adapter: SECOND_TARGET_ADAPTER, + adapterMarketCapId: secondTargetAdapterMarketCapId, + absoluteCap: 10_000n, + canPullFromMarket: false, + }, + }, + }, + }); + + const initialCanonicalMarket = sharedData.getMarket(targetParams.id); + expect(sharedData.getAdapter(VAULT, TARGET_ADAPTER).markets[0]).toBe( + initialCanonicalMarket, + ); + expect( + sharedData.getAdapter(SECOND_VAULT, SECOND_TARGET_ADAPTER).markets[0], + ).toBe(initialCanonicalMarket); + + const result = sharedData.computeVaultV2BlueReallocations(targetParams.id); + const finalCanonicalMarket = result.data.getMarket(targetParams.id); + + expect(result.reallocations).toHaveLength(2); + expect(result.data.getAdapter(VAULT, TARGET_ADAPTER).markets[0]).toBe( + finalCanonicalMarket, + ); + expect( + result.data.getAdapter(SECOND_VAULT, SECOND_TARGET_ADAPTER).markets[0], + ).toBe(finalCanonicalMarket); + }); + + test("behavior: deep-clones legacy and nested accrued adapters", () => { + const { data, targetAdapterMarketCapId } = makeFixture(); + const targetMarket = data.getMarket(targetParams.id); + const legacyPosition = new AccrualPosition( + { + user: LEGACY_MARKET_ADAPTER, + supplyShares: targetMarket.toSupplyShares(25n, "Down"), + borrowShares: 0n, + collateral: 0n, + }, + targetMarket, + ); + const legacyAdapter = new AccrualVaultV2MorphoMarketV1Adapter( + { + address: LEGACY_MARKET_ADAPTER, + parentVault: VAULT, + skimRecipient: zeroAddress, + marketParamsList: [targetParams], + }, + [legacyPosition], + ); + const nestedPosition = new AccrualPosition( + { + user: NESTED_VAULT, + supplyShares: targetMarket.toSupplyShares(30n, "Down"), + borrowShares: 0n, + collateral: 0n, + }, + targetMarket, + ); + const nestedVault = new AccrualVault( + { + address: NESTED_VAULT, + name: "Nested Vault", + symbol: "nv", + asset: LOAN_TOKEN, + decimalsOffset: 0n, + curator: VAULT, + owner: VAULT, + guardian: VAULT, + fee: 0n, + feeRecipient: VAULT, + skimRecipient: VAULT, + pendingTimelock: { value: 1n, validAt: TIMESTAMP + 1n }, + pendingGuardian: { value: VAULT, validAt: TIMESTAMP + 2n }, + pendingOwner: VAULT, + timelock: 0n, + supplyQueue: [targetMarket.id], + totalSupply: 30n, + lastTotalAssets: 30n, + publicAllocatorConfig: { + admin: VAULT, + fee: 1n, + accruedFee: 2n, + }, + }, + [ + { + config: { + vault: NESTED_VAULT, + marketId: targetMarket.id, + cap: 1_000n, + pendingCap: { value: 2_000n, validAt: TIMESTAMP + 3n }, + removableAt: 0n, + enabled: true, + publicAllocatorConfig: { + vault: NESTED_VAULT, + marketId: targetMarket.id, + maxIn: 100n, + maxOut: 200n, + }, + }, + position: nestedPosition, + }, + ], + ); + const nestedAdapter = new AccrualVaultV2MorphoVaultV1Adapter( + { + address: VAULT_V1_ADAPTER, + parentVault: VAULT, + skimRecipient: zeroAddress, + morphoVaultV1: NESTED_VAULT, + }, + nestedVault, + 30n, + ); + const fixtureVault = data.getVault(VAULT); + const inputVault = new AccrualVaultV2( + { + ...fixtureVault, + liquidityAllocations: fixtureVault.liquidityAllocations?.map( + (allocation) => ({ ...allocation }), + ), + }, + fixtureVault.accrualLiquidityAdapter, + [...fixtureVault.accrualAdapters, legacyAdapter, nestedAdapter], + fixtureVault.assetBalance, + fixtureVault.forceDeallocatePenalties, + ); + const input = new VaultV2BlueReallocationData({ + chainId: data.chainId, + markets: data.markets, + vaults: { [VAULT]: inputVault }, + allocations: data.allocations, + publicAllocatorConfigs: data.publicAllocatorConfigs, + activeAdapters: data.activeAdapters, + marketPublicAllocatorConfigs: data.marketPublicAllocatorConfigs, + }); + + const cloned = input.clone(); + expect(cloned.publicAllocatorConfigs).toBe(input.publicAllocatorConfigs); + expect(cloned.activeAdapters).toBe(input.activeAdapters); + expect(cloned.marketPublicAllocatorConfigs).toBe( + input.marketPublicAllocatorConfigs, + ); + expect(cloned.allocations[VAULT]).not.toBe(input.allocations[VAULT]); + expect(cloned.getAllocation(VAULT, targetAdapterMarketCapId)).toBe( + input.getAllocation(VAULT, targetAdapterMarketCapId), + ); + expect(cloned.getPublicAllocatorConfig(VAULT)).toBeInstanceOf( + VaultV2BluePublicAllocatorConfig, + ); + expect( + cloned.getMarketPublicAllocatorConfig(VAULT, targetAdapterMarketCapId), + ).toBeInstanceOf(VaultV2BlueMarketPublicAllocatorConfig); + const inputLegacy = input + .getVault(VAULT) + .accrualAdapters.find( + (adapter) => adapter instanceof AccrualVaultV2MorphoMarketV1Adapter, + ); + const clonedLegacy = cloned + .getVault(VAULT) + .accrualAdapters.find( + (adapter) => adapter instanceof AccrualVaultV2MorphoMarketV1Adapter, + ); + const inputNested = input + .getVault(VAULT) + .accrualAdapters.find( + (adapter) => adapter instanceof AccrualVaultV2MorphoVaultV1Adapter, + ); + const clonedNested = cloned + .getVault(VAULT) + .accrualAdapters.find( + (adapter) => adapter instanceof AccrualVaultV2MorphoVaultV1Adapter, + ); + + expect(clonedLegacy).not.toBe(inputLegacy); + expect(clonedLegacy?.positions[0]).not.toBe(inputLegacy?.positions[0]); + expect(clonedLegacy?.positions[0]?.market).not.toBe( + inputLegacy?.positions[0]?.market, + ); + expect(clonedNested).not.toBe(inputNested); + expect(clonedNested?.accrualVaultV1).not.toBe(inputNested?.accrualVaultV1); + expect(clonedNested?.accrualVaultV1.allocations).not.toBe( + inputNested?.accrualVaultV1.allocations, + ); + + const simulated = input.computeVaultV2BlueReallocations( + targetMarket.id, + ).data; + const simulatedLegacy = simulated + .getVault(VAULT) + .accrualAdapters.find( + (adapter) => adapter instanceof AccrualVaultV2MorphoMarketV1Adapter, + ); + const simulatedNested = simulated + .getVault(VAULT) + .accrualAdapters.find( + (adapter) => adapter instanceof AccrualVaultV2MorphoVaultV1Adapter, + ); + const simulatedTargetMarket = simulated.getMarket(targetMarket.id); + expect(simulatedLegacy?.positions[0]?.market.totalSupplyAssets).toBe( + simulatedTargetMarket.totalSupplyAssets, + ); + expect( + simulatedNested?.accrualVaultV1.allocations.get(targetMarket.id)?.position + .market.totalSupplyAssets, + ).toBe(simulatedTargetMarket.totalSupplyAssets); + + clonedLegacy!.positions[0]!.supplyShares += 1n; + clonedNested!.accrualVaultV1.pendingTimelock.value = 99n; + const clonedNestedAllocation = clonedNested!.accrualVaultV1.allocations.get( + targetMarket.id, + )!; + clonedNestedAllocation.config.pendingCap.value = 88n; + clonedNestedAllocation.position.supplyShares += 2n; + + expect(inputLegacy?.positions[0]?.supplyShares).toBe( + legacyPosition.supplyShares, + ); + expect(inputNested?.accrualVaultV1.pendingTimelock.value).toBe(1n); + expect( + inputNested?.accrualVaultV1.allocations.get(targetMarket.id)?.config + .pendingCap.value, + ).toBe(2_000n); + expect( + inputNested?.accrualVaultV1.allocations.get(targetMarket.id)?.position + .supplyShares, + ).toBe(nestedPosition.supplyShares); + }); + + test("behavior: repeated cap probes are deterministic and isolated", () => { + const { data } = makeFixture({ idle: 300n }); + const initialData = data.clone(); + + const first = data.computeVaultV2BlueReallocations(targetParams.id); + const second = data.computeVaultV2BlueReallocations(targetParams.id); + + expect(second.reallocations).toStrictEqual(first.reallocations); + expect(second.data).toStrictEqual(first.data); + expect(data).toStrictEqual(initialData); + }); + + test("behavior: ranks market liquidity before idle and depletes both sources", () => { + const { data, sourceExpectedAssets } = makeFixture({ idle: 300n }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect( + result.reallocations.map(({ from, assets, penalty }) => ({ + from: from.type, + assets, + penalty, + })), + ).toStrictEqual([ + { from: "market", assets: sourceExpectedAssets, penalty: 0n }, + { from: "idle", assets: 300n, penalty: 0n }, + ]); + expect(result.data.getVault(VAULT).assetBalance).toBe(0n); + }); + + test("behavior: excludes the target market through a different adapter", () => { + const { data } = makeFixture({ + sourceMarketParams: targetParams, + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: allows deallocation assets to exceed stored allocation", () => { + const { data, sourceExpectedAssets } = makeFixture({ + sourceUntracked: 900n, + }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect(result.reallocations[0]?.assets).toBe(sourceExpectedAssets); + expect(result.data.getVault(VAULT).assetBalance).toBe(0n); + }); + + test("behavior: target untracked interest consumes allocator headroom", () => { + const { data } = makeFixture({ + targetPositionAssets: 100n, + targetUntracked: 10n, + allocatorTargetCap: 100n, + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: shared collateral ids retain both markets' untracked interest", () => { + const sharedCollateralSource = new MarketParams({ + ...sourceParams, + collateralToken: targetParams.collateralToken, + }); + const { data } = makeFixture({ + sourceMarketParams: sharedCollateralSource, + sourceUntracked: 10n, + targetPositionAssets: 100n, + targetUntracked: 20n, + targetCaps: [ + { absoluteCap: 10_000n, relativeCap: MathLib.WAD }, + { absoluteCap: 1_099n, relativeCap: MathLib.WAD }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD }, + ], + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: freezes firstTotalAssets while applying relative caps", () => { + const { data } = makeFixture({ + firstTotalAssets: 1_000n, + targetCaps: [ + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + ], + }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect(result.reallocations[0]?.assets).toBe(500n); + expect(result.data.getVault(VAULT)._totalAssets).toBe(1_000n); + }); + + test("behavior: recognizes zero-elapsed losses at the one-unit relative-cap boundary", () => { + const { data, targetAdapterMarketCapId } = makeFixture({ + sourceSupply: 0n, + targetSupply: 0n, + firstTotalAssets: 1_000n, + idle: 900n, + canPullFromMarket: false, + penalty: 0n, + targetCaps: [ + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + ], + }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect(result.reallocations).toHaveLength(1); + expect(result.reallocations[0]?.assets).toBe(450n); + expect( + result.data.getAllocation(VAULT, targetAdapterMarketCapId).allocation, + ).toBe(450n); + expect(result.data.getVault(VAULT)._totalAssets).toBe(900n); + }); + + test("behavior: reuses firstTotalAssets across two reallocations for one vault", () => { + const relativeCap = (MathLib.WAD * 3n) / 4n; + const { data } = makeFixture({ + sourceSupply: 500n, + targetSupply: 0n, + firstTotalAssets: 1_000n, + idle: 400n, + penalty: 0n, + targetCaps: [ + { absoluteCap: 10_000n, relativeCap }, + { absoluteCap: 10_000n, relativeCap }, + { absoluteCap: 10_000n, relativeCap }, + ], + }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id); + + expect( + result.reallocations.map(({ from, assets }) => ({ + from: from.type, + assets, + })), + ).toStrictEqual([ + { from: "market", assets: 500n }, + { from: "idle", assets: 175n }, + ]); + expect(result.data.getVault(VAULT)._totalAssets).toBe(900n); + }); + + test("behavior: caps each call at uint128", () => { + const sourceSupply = MathLib.MAX_UINT_128 + 10n; + const { data } = makeFixture({ + sourceSupply, + targetSupply: 0n, + firstTotalAssets: sourceSupply, + allocatorTargetCap: MathLib.MAX_UINT_256, + targetCaps: [ + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + ], + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations[0] + ?.assets, + ).toBe(MathLib.MAX_UINT_128); + }); + + test("behavior: rejects target market supply overflow", () => { + const { data } = makeFixture({ + targetSupply: MathLib.MAX_UINT_128, + firstTotalAssets: MathLib.MAX_UINT_128 + 1_000n, + allocatorTargetCap: MathLib.MAX_UINT_256, + targetCaps: [ + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + ], + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: same-market deallocation is not counted as target liquidity", () => { + const { data } = makeFixture({ + sourceMarketParams: targetParams, + sourceSupply: MathLib.MAX_UINT_128, + firstTotalAssets: MathLib.MAX_UINT_128, + allocatorTargetCap: MathLib.MAX_UINT_256, + targetCaps: [ + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + { absoluteCap: MathLib.MAX_UINT_256, relativeCap: MathLib.WAD }, + ], + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: defaults to the latest market or vault update timestamp", () => { + const { data } = makeFixture({ + sourceLastUpdate: TIMESTAMP + 1n, + vaultLastUpdate: TIMESTAMP + 2n, + }); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id), + ).not.toThrow(); + }); + + test("behavior: freezes firstTotalAssets after the first penalty donation", () => { + const { data } = makeFixture({ + sourceSupply: 500n, + targetSupply: 0n, + firstTotalAssets: 500n, + maxRate: MathLib.WAD, + vaultLastUpdate: TIMESTAMP - 1n, + penalty: MathLib.WAD, + allocatorTargetCap: 10_000n, + targetCaps: [ + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + { absoluteCap: 10_000n, relativeCap: MathLib.WAD / 2n }, + ], + }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id, { + maxPenalty: MathLib.WAD, + }); + + expect(result.reallocations[0]?.assets).toBe(500n); + expect(result.data.getVault(VAULT)._totalAssets).toBe(1_000n); + }); + + test("behavior: disabled discovery returns no calls", () => { + const { data } = makeFixture(); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id, { enabled: false }) + .reallocations, + ).toStrictEqual([]); + }); + + test("error: UnknownReallocationMarketError with an explicit timestamp", () => { + const data = new VaultV2BlueReallocationData({ + chainId: ChainId.EthMainnet, + }); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + timestamp: TIMESTAMP, + }), + ).toThrow(UnknownReallocationMarketError); + }); + + test("error: UnknownReallocationMarketError for incomplete source state", () => { + const { data } = makeFixture(); + const incompleteData = data.clone(); + delete (incompleteData.markets as Record)[ + sourceParams.id + ]; + vi.spyOn(data, "clone").mockReturnValue(incompleteData); + + expect(() => data.computeVaultV2BlueReallocations(targetParams.id)).toThrow( + UnknownReallocationMarketError, + ); + }); + + test("error: UnknownReallocationActiveAdaptersError for incomplete vault state", () => { + const { data } = makeFixture(); + delete ( + data.activeAdapters as Record | undefined> + )[VAULT]; + + expect(() => data.computeVaultV2BlueReallocations(targetParams.id)).toThrow( + UnknownReallocationActiveAdaptersError, + ); + }); + + test("error: UnknownReallocationPublicAllocatorConfigError for incomplete vault state", () => { + const { data } = makeFixture(); + delete ( + data.publicAllocatorConfigs as Record< + Address, + VaultV2BluePublicAllocatorConfig | undefined + > + )[VAULT]; + + expect(() => data.computeVaultV2BlueReallocations(targetParams.id)).toThrow( + UnknownReallocationPublicAllocatorConfigError, + ); + }); + + test("behavior: ignores a vault with fetched but absent allocator config", () => { + const { data } = makeFixture(); + ( + data.publicAllocatorConfigs as Record< + Address, + VaultV2BluePublicAllocatorConfig | undefined + > + )[VAULT] = undefined; + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + }); + + test("behavior: ignores vault liquidity above the penalty threshold", () => { + const { data, sourceExpectedAssets } = makeFixture({ + idle: 300n, + penalty: 8n, + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id).reallocations, + ).toStrictEqual([]); + expect( + data.computeVaultV2BlueReallocations(targetParams.id, { + maxPenalty: 7n, + }).reallocations, + ).toStrictEqual([]); + expect( + data + .computeVaultV2BlueReallocations(targetParams.id, { + maxPenalty: 8n, + }) + .reallocations.map(({ from, assets }) => ({ + from: from.type, + assets, + })), + ).toStrictEqual([ + { from: "market", assets: sourceExpectedAssets }, + { from: "idle", assets: 300n }, + ]); + }); +}); + +describe("VaultV2BlueReallocationData.computeVaultV2BlueReallocations operation", () => { + test("default: caps friendly reallocations to the 90% target", () => { + const { data } = makeFixture({ targetSupply: 100n, targetBorrow: 90n }); + + const result = data.computeVaultV2BlueReallocations(targetParams.id, { + operation: { type: "borrow", amount: 20n }, + }); + + expect(result.reallocations).toHaveLength(1); + expect(result.reallocations[0]?.assets).toBe(23n); + expect(result.data.getMarket(targetParams.id).totalSupplyAssets).toBe(123n); + }); + + test("error: validates maxWithdrawalUtilization before an operation early return", () => { + const { data } = makeFixture({ targetSupply: 100n, targetBorrow: 0n }); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + maxWithdrawalUtilization: MathLib.WAD + 1n, + operation: { type: "borrow", amount: 1n }, + }), + ).toThrow(InputExceedsMaxError); + }); + + test("behavior: applies the configured ceiling during the friendly phase", () => { + const { data } = makeFixture({ + targetSupply: 100n, + targetBorrow: 90n, + sourceBorrow: 900n, + }); + + const { reallocations } = data.computeVaultV2BlueReallocations( + targetParams.id, + { + maxWithdrawalUtilization: MathLib.WAD, + operation: { type: "borrow", amount: 20n }, + }, + ); + + expect(reallocations[0]?.assets).toBe(23n); + }); + + test("behavior: rounds required supply up to the utilization target", () => { + const { data } = makeFixture({ targetSupply: 1n, targetBorrow: 0n }); + + const { reallocations } = data.computeVaultV2BlueReallocations( + targetParams.id, + { operation: { type: "borrow", amount: 1n } }, + ); + + expect(reallocations[0]?.assets).toBe(1n); + }); + + test("behavior: plans at the latest snapshot timestamp by default", () => { + const { data } = makeFixture({ + targetSupply: 100n, + targetBorrow: 90n, + sourceLastUpdate: TIMESTAMP + 2n, + }); + + const defaultReallocations = data.computeVaultV2BlueReallocations( + targetParams.id, + { operation: { type: "borrow", amount: 20n } }, + ); + const explicitReallocations = data.computeVaultV2BlueReallocations( + targetParams.id, + { + timestamp: TIMESTAMP + 2n, + operation: { type: "borrow", amount: 20n }, + }, + ); + + expect(defaultReallocations).toStrictEqual(explicitReallocations); + }); + + test("behavior: falls back to a 100% source-utilization ceiling", () => { + const { data } = makeFixture({ + targetSupply: 100n, + targetBorrow: 100n, + sourceSupply: 1_000n, + sourceBorrow: 950n, + }); + + const { reallocations } = data.computeVaultV2BlueReallocations( + targetParams.id, + { + reallocatableVaults: [VAULT as Address].values(), + maxWithdrawalUtilization: 950_000_000_000_000_000n, + operation: { type: "borrow", amount: 40n }, + }, + ); + + expect(reallocations[0]?.assets).toBe(40n); + }); + + test("behavior: plans a loan-asset withdraw", () => { + const { data } = makeFixture({ targetSupply: 100n, targetBorrow: 90n }); + + const { reallocations } = data.computeVaultV2BlueReallocations( + targetParams.id, + { operation: { type: "withdraw", amount: 10n } }, + ); + + expect(reallocations[0]?.assets).toBe(10n); + }); + + test("behavior: preserves the configured penalty for every retained flat call", () => { + const { data } = makeFixture({ + targetSupply: 100n, + targetBorrow: 100n, + idle: 300n, + penalty: 7n, + }); + const { reallocations } = data.computeVaultV2BlueReallocations( + targetParams.id, + { + maxPenalty: 7n, + operation: { type: "borrow", amount: 1_100n }, + }, + ); + + const tx = blueBorrow({ + market: { + chainId: ChainId.EthMainnet, + marketParams: targetParams, + }, + args: { + amount: 1_100n, + receiver: VAULT, + minSharePrice: 0n, + reallocations, + }, + }); + + expect(reallocations).toHaveLength(2); + expect(tx.value).toBe(0n); + expect(tx.action.args.reallocationPenaltyAssets).toBe(2n); + }); + + test("behavior: excludes reallocations above the penalty threshold", () => { + const { data } = makeFixture({ + targetSupply: 100n, + targetBorrow: 90n, + penalty: 7n, + }); + + expect( + data.computeVaultV2BlueReallocations(targetParams.id, { + maxPenalty: 6n, + operation: { type: "borrow", amount: 1n }, + }).reallocations, + ).toStrictEqual([]); + expect( + data.computeVaultV2BlueReallocations(targetParams.id, { + maxPenalty: 7n, + operation: { type: "borrow", amount: 1n }, + }).reallocations[0]?.assets, + ).toBe(2n); + }); + + test("error: InsufficientSharedLiquidityError rejects a partial plan", () => { + const { data } = makeFixture({ + targetSupply: 100n, + targetBorrow: 100n, + sourceSupply: 50n, + }); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + operation: { type: "borrow", amount: 100n }, + }), + ).toThrow(InsufficientSharedLiquidityError); + }); + + test("error: ReallocationWithdrawExceedsMarketSupplyError", () => { + const { data } = makeFixture({ targetSupply: 100n }); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + operation: { type: "withdraw", amount: 101n }, + }), + ).toThrow(ReallocationWithdrawExceedsMarketSupplyError); + }); + + test.each([ + { operation: "borrow", amount: 0n }, + { operation: "borrow", amount: -1n }, + { operation: "withdraw", amount: 0n }, + { operation: "withdraw", amount: -1n }, + ] as const)( + "error: NonPositiveInputError for $operation amount $amount", + ({ operation, amount }) => { + const { data } = makeFixture({ targetSupply: 100n, targetBorrow: 95n }); + const initialData = data.clone(); + + expect(() => + data.computeVaultV2BlueReallocations(targetParams.id, { + operation: { type: operation, amount }, + }), + ).toThrow(NonPositiveInputError); + expect(data).toStrictEqual(initialData); + }, + ); + + test("behavior: disabled planning returns no calls", () => { + const { data } = makeFixture(); + + const result = data.computeVaultV2BlueReallocations(targetParams.id, { + enabled: false, + operation: { type: "borrow", amount: 0n }, + }); + + expect(result.reallocations).toStrictEqual([]); + expect(result.data).toBe(data); + }); +}); + +describe("VaultV2BlueReallocationData liquidity metrics", () => { + test("default: sums idle and market liquidity in target-utilization math", () => { + const { data, sourceExpectedAssets } = makeFixture({ + targetSupply: 100n, + targetBorrow: 50n, + idle: 300n, + }); + + expect(data.getPublicReallocationLiquidity(targetParams.id)).toBe( + sourceExpectedAssets + 300n, + ); + expect(data.getAvailableLiquidityToUtilization(targetParams.id)).toBe( + 1_210n, + ); + expect( + data.getAvailableLiquidityToUtilization( + targetParams.id, + (MathLib.WAD * 8n) / 10n, + ), + ).toBe(30n); + }); + + test("behavior: applies the configured source-utilization ceiling", () => { + const { data } = makeFixture({ sourceBorrow: 900n }); + + expect( + data.getPublicReallocationLiquidity(targetParams.id, { + maxWithdrawalUtilization: MathLib.WAD, + }), + ).toBe(100n); + }); + + test("error: NegativeInputError for a negative maximum penalty", () => { + const { data } = makeFixture(); + + expect(() => + data.getPublicReallocationLiquidity(targetParams.id, { maxPenalty: -1n }), + ).toThrow(NegativeInputError); + expect(() => + data.getAvailableLiquidityToUtilization(targetParams.id, MathLib.WAD, { + maxPenalty: -1n, + }), + ).toThrow(NegativeInputError); + }); +}); diff --git a/packages/morpho-sdk/src/entities/vaultV2BlueReallocationData.ts b/packages/morpho-sdk/src/entities/vaultV2BlueReallocationData.ts new file mode 100644 index 000000000..a015cd75c --- /dev/null +++ b/packages/morpho-sdk/src/entities/vaultV2BlueReallocationData.ts @@ -0,0 +1,1572 @@ +import { + AccrualPosition, + AccrualVault, + AccrualVaultV2, + AccrualVaultV2MorphoMarketV1Adapter, + AccrualVaultV2MorphoMarketV1AdapterV2, + AccrualVaultV2MorphoVaultV1Adapter, + type IAccrualVaultV2Adapter, + type IVaultV2Allocation, + type IVaultV2BlueMarketPublicAllocatorConfig, + type IVaultV2BluePublicAllocatorConfig, + Market, + type MarketId, + MarketUtils, + MathLib, + UnknownDataError, + UnsupportedVaultV2AdapterError, + VaultV2BlueMarketPublicAllocatorConfig, + VaultV2BluePublicAllocatorConfig, + VaultV2BluePublicAllocatorConfigUtils, + VaultV2Utils, +} from "@morpho-org/blue-sdk"; +import { _try, bigIntComparator } from "@morpho-org/morpho-ts"; +import { type Address, type Hash, isAddressEqual } from "viem"; +import { + DEFAULT_MAX_REALLOCATION_PENALTY, + DEFAULT_SUPPLY_TARGET_UTILIZATION, + DEFAULT_WITHDRAWAL_TARGET_UTILIZATION, + MAX_REALLOCATION_PENALTY, +} from "../helpers/constant.js"; +import type { + VaultV2BluePublicAllocatorOptions, + VaultV2BlueReallocation, +} from "../types/index.js"; +import { + InputExceedsMaxError, + InsufficientSharedLiquidityError, + NegativeInputError, + NonPositiveInputError, + ReallocationAdapterSupplySharesUnderflowError, + ReallocationAllocationUnderflowError, + ReallocationWithdrawExceedsMarketSupplyError, + UnknownReallocationActiveAdaptersError, + UnknownReallocationAdapterError, + UnknownReallocationAllocationError, + UnknownReallocationMarketError, + UnknownReallocationMarketPublicAllocatorConfigError, + UnknownReallocationPublicAllocatorConfigError, + UnknownReallocationVaultError, +} from "../types/index.js"; + +type ReadonlyMarketSnapshot = Readonly; + +type ReadonlyAdapterSnapshot = Readonly; + +type ReadonlyMarketAdapterSnapshot = Readonly< + Omit< + AccrualVaultV2MorphoMarketV1AdapterV2, + "marketIds" | "supplyShares" | "markets" + > +> & { + readonly marketIds: readonly MarketId[]; + readonly supplyShares: Readonly>; + readonly markets: readonly ReadonlyMarketSnapshot[]; +}; + +type ReadonlyVaultSnapshot = Readonly< + Omit< + AccrualVaultV2, + | "adapters" + | "liquidityAllocations" + | "accrualLiquidityAdapter" + | "accrualAdapters" + | "forceDeallocatePenalties" + > +> & { + readonly adapters: readonly Address[]; + readonly liquidityAllocations: + | readonly Readonly[] + | undefined; + readonly accrualLiquidityAdapter: ReadonlyAdapterSnapshot | undefined; + readonly accrualAdapters: readonly ReadonlyAdapterSnapshot[]; + readonly forceDeallocatePenalties: Readonly>; +}; + +type AdapterIds = ReturnType; + +/** Input state required to simulate Vault V2 BluePublicAllocator reallocations. */ +export interface InputVaultV2BlueReallocationData { + /** Chain id associated with the fetched state. */ + readonly chainId: number; + /** Markets indexed by market id. */ + readonly markets?: Readonly< + Record + >; + /** Accrued Vault V2 entities indexed by vault address. */ + readonly vaults?: Readonly< + Record + >; + /** Vault cap state indexed by vault address and derived allocation id. */ + readonly allocations?: Readonly< + Record< + Address, + Readonly> | undefined + > + >; + /** Vault-wide BluePublicAllocator configuration indexed by vault address. */ + readonly publicAllocatorConfigs?: Readonly< + Record + >; + /** + * BluePublicAllocator-active adapters indexed by vault address. + * Arrays, readonly arrays, sets, and other iterables are copied to sets + * without changing address casing. + */ + readonly activeAdapters?: Readonly< + Record | undefined> + >; + /** Adapter-market BluePublicAllocator configuration indexed by vault and `adapterMarketCapId`. */ + readonly marketPublicAllocatorConfigs?: Readonly< + Record< + Address, + | Readonly< + Record + > + | undefined + > + >; +} + +const cloneMarket = (market: ReadonlyMarketSnapshot) => + new Market({ ...market }); + +/** + * Finds an address key without changing its supplied casing. + * + * @param record - Address-keyed record to search. + * @param address - Address to match case-insensitively. + * @returns The stored key, or `undefined` when no address matches. + * @internal + */ +const findAddressKey = ( + record: Readonly>, + address: Address, +) => { + if (Object.hasOwn(record, address)) return address; + return (Object.keys(record) as Address[]).find((key) => + isAddressEqual(key, address), + ); +}; + +/** + * Returns cached allocation ids for one adapter-market pair. + * + * @param cache - Per-planning-call adapter id cache. + * @param adapter - Adapter that derives the allocation ids. + * @param market - Market whose params identify the allocations. + * @returns The adapter, collateral, and adapter-market cap ids. + * @internal + */ +// biome-ignore lint/complexity/useMaxParams: cache lookup requires both adapter and market identity. +const getAdapterIds = ( + cache: Map, + adapter: ReadonlyMarketAdapterSnapshot, + market: ReadonlyMarketSnapshot, +) => { + const key = `${adapter.address}:${market.id}`; + const cached = cache.get(key); + if (cached != null) return cached; + + const ids = adapter.ids(market.params); + cache.set(key, ids); + return ids; +}; + +const resolveMaxWithdrawalUtilization = (value: bigint | undefined) => { + const utilization = value ?? DEFAULT_WITHDRAWAL_TARGET_UTILIZATION; + if (utilization < 0n) + throw new NegativeInputError("maxWithdrawalUtilization", utilization); + if (utilization > MathLib.WAD) + throw new InputExceedsMaxError({ + field: "maxWithdrawalUtilization", + value: utilization, + max: MathLib.WAD, + }); + return utilization; +}; + +const resolveMaxPenalty = (value: bigint | undefined) => { + const penalty = value ?? DEFAULT_MAX_REALLOCATION_PENALTY; + if (penalty < 0n) throw new NegativeInputError("maxPenalty", penalty); + if (penalty > MAX_REALLOCATION_PENALTY) + throw new InputExceedsMaxError({ + field: "maxPenalty", + value: penalty, + max: MAX_REALLOCATION_PENALTY, + }); + return penalty; +}; + +const getCanonicalMarket = ( + markets: Record, + market: Market, +) => (markets[market.id] ??= cloneMarket(market)); + +const clonePosition = ( + position: AccrualPosition, + markets: Record, +) => + new AccrualPosition(position, getCanonicalMarket(markets, position.market)); + +const cloneAccrualVault = ( + vault: AccrualVault, + markets: Record, +) => + new AccrualVault( + { + ...vault, + pendingTimelock: { ...vault.pendingTimelock }, + pendingGuardian: { ...vault.pendingGuardian }, + supplyQueue: [...vault.supplyQueue], + publicAllocatorConfig: + vault.publicAllocatorConfig == null + ? undefined + : { ...vault.publicAllocatorConfig }, + }, + [...vault.allocations.values()].map(({ config, position }) => ({ + config: { + ...config, + pendingCap: { ...config.pendingCap }, + publicAllocatorConfig: + config.publicAllocatorConfig == null + ? undefined + : { ...config.publicAllocatorConfig }, + }, + position: clonePosition(position, markets), + })), + ); + +const cloneAdapter = ( + adapter: IAccrualVaultV2Adapter, + markets: Record, +) => { + const base = { + address: adapter.address, + parentVault: adapter.parentVault, + skimRecipient: adapter.skimRecipient, + }; + + if (adapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2) + return new AccrualVaultV2MorphoMarketV1AdapterV2( + { + ...base, + marketIds: [...adapter.marketIds], + adaptiveCurveIrm: adapter.adaptiveCurveIrm, + supplyShares: { ...adapter.supplyShares }, + }, + // V2 adapters can retain the canonical instance directly, so every + // adapter observes the same global Morpho market state. + adapter.markets.map((market) => getCanonicalMarket(markets, market)), + ); + + if (adapter instanceof AccrualVaultV2MorphoMarketV1Adapter) + return new AccrualVaultV2MorphoMarketV1Adapter( + { ...base, marketParamsList: [...adapter.marketParamsList] }, + adapter.positions.map((position) => clonePosition(position, markets)), + ); + + if (adapter instanceof AccrualVaultV2MorphoVaultV1Adapter) + return new AccrualVaultV2MorphoVaultV1Adapter( + { ...base, morphoVaultV1: adapter.morphoVaultV1 }, + cloneAccrualVault(adapter.accrualVaultV1, markets), + adapter.shares, + ); + + throw new UnsupportedVaultV2AdapterError(adapter.address); +}; + +const cloneVault = ( + vault: ReadonlyVaultSnapshot, + markets: Record, +) => { + const adapters = vault.accrualAdapters.map((adapter) => + cloneAdapter(adapter, markets), + ); + const liquidityAdapter = + vault.accrualLiquidityAdapter == null + ? undefined + : (adapters.find((adapter) => + isAddressEqual( + adapter.address, + vault.accrualLiquidityAdapter!.address, + ), + ) ?? cloneAdapter(vault.accrualLiquidityAdapter, markets)); + + return new AccrualVaultV2( + { + ...vault, + liquidityAllocations: vault.liquidityAllocations?.map((allocation) => ({ + ...allocation, + })), + }, + liquidityAdapter, + adapters, + vault.assetBalance, + { ...vault.forceDeallocatePenalties }, + ); +}; + +/** + * Immutable-by-convention state container for Vault V2 BluePublicAllocator simulations. + * + * Constructor inputs are cloned. Every simulated reallocation returns a new + * instance. The first allocation for each vault accrues it in contract order + * after the penalty donation and any source deallocation, then freezes that + * `_totalAssets` value as `firstTotalAssets` for the rest of the plan. + * Address keys and values retain their supplied casing; lookups compare them + * case-insensitively. + * + * @example + * ```ts + * import { VaultV2BlueReallocationData } from "@morpho-org/morpho-sdk/entities"; + * + * const data = new VaultV2BlueReallocationData(input); + * ``` + */ +export class VaultV2BlueReallocationData + implements InputVaultV2BlueReallocationData +{ + /** Mutable market state used only by cloned simulation transitions. */ + private readonly mutableMarkets: Record; + /** Mutable vault state used only by cloned simulation transitions. */ + private readonly mutableVaults: Record; + /** Mutable allocation state used only by cloned simulation transitions. */ + private readonly mutableAllocations: Record< + Address, + Record | undefined + >; + /** Penalty donations created by this simulation, excluded as fresh shared-liquidity sources. */ + private readonly donatedPenaltyAssets: Record; + /** Transaction-frozen cap denominator for each vault touched by this plan. */ + private readonly firstTotalAssets: Record; + /** Chain id associated with this snapshot. */ + public readonly chainId: number; + /** Markets indexed by market id. */ + public readonly markets: Readonly< + Record + >; + /** Vault V2 entities indexed by address. */ + public readonly vaults: Readonly< + Record + >; + /** Vault cap state indexed by vault and derived allocation id. */ + public readonly allocations: Readonly< + Record< + Address, + | Readonly | undefined>> + | undefined + > + >; + /** Vault-wide allocator configuration indexed by vault. */ + public readonly publicAllocatorConfigs: Readonly< + Record + >; + /** BluePublicAllocator-active adapters indexed by vault address, preserving vault and adapter casing. */ + public readonly activeAdapters: Readonly< + Record | undefined> + >; + /** Adapter-market allocator configuration indexed by vault and market-params id. */ + public readonly marketPublicAllocatorConfigs: Readonly< + Record< + Address, + | Readonly< + Record + > + | undefined + > + >; + + /** + * Creates a cloned Vault V2 reallocation snapshot. + * + * @param input - State fetched at one consistent block. + * @throws {UnsupportedVaultV2AdapterError} when a vault contains an unsupported adapter type. + */ + public constructor(input: InputVaultV2BlueReallocationData) { + const isClone = input instanceof VaultV2BlueReallocationData; + this.chainId = input.chainId; + this.mutableMarkets = {}; + this.mutableVaults = {}; + this.mutableAllocations = {}; + this.markets = this.mutableMarkets; + this.vaults = this.mutableVaults; + this.allocations = this.mutableAllocations; + if (isClone) { + this.publicAllocatorConfigs = input.publicAllocatorConfigs; + this.activeAdapters = input.activeAdapters; + this.marketPublicAllocatorConfigs = input.marketPublicAllocatorConfigs; + this.donatedPenaltyAssets = { ...input.donatedPenaltyAssets }; + this.firstTotalAssets = { ...input.firstTotalAssets }; + } else { + const publicAllocatorConfigs: Record< + Address, + VaultV2BluePublicAllocatorConfig | undefined + > = {}; + this.publicAllocatorConfigs = publicAllocatorConfigs; + const activeAdapters: Record | undefined> = + {}; + this.activeAdapters = activeAdapters; + const marketPublicAllocatorConfigs: Record< + Address, + | Record + | undefined + > = {}; + this.marketPublicAllocatorConfigs = marketPublicAllocatorConfigs; + this.donatedPenaltyAssets = {}; + this.firstTotalAssets = {}; + + for (const [vault, config] of Object.entries( + input.publicAllocatorConfigs ?? {}, + ) as [Address, IVaultV2BluePublicAllocatorConfig | undefined][]) { + publicAllocatorConfigs[vault] = + config == null + ? undefined + : new VaultV2BluePublicAllocatorConfig(config); + } + + for (const [vault, adapters] of Object.entries( + input.activeAdapters ?? {}, + ) as [Address, Iterable
| undefined][]) { + activeAdapters[vault] = + adapters == null ? undefined : new Set(adapters); + } + + for (const [vault, configs] of Object.entries( + input.marketPublicAllocatorConfigs ?? {}, + ) as [ + Address, + ( + | Readonly< + Record + > + | undefined + ), + ][]) { + marketPublicAllocatorConfigs[vault] = {}; + for (const [id, config] of Object.entries(configs ?? {}) as [ + Hash, + IVaultV2BlueMarketPublicAllocatorConfig | undefined, + ][]) { + marketPublicAllocatorConfigs[vault]![id] = + config == null + ? undefined + : new VaultV2BlueMarketPublicAllocatorConfig(config); + } + } + } + + for (const [marketId, market] of Object.entries(input.markets ?? {}) as [ + MarketId, + ReadonlyMarketSnapshot | undefined, + ][]) { + this.mutableMarkets[marketId] = + market == null ? undefined : cloneMarket(market); + } + + for (const [address, vault] of Object.entries(input.vaults ?? {}) as [ + Address, + ReadonlyVaultSnapshot | undefined, + ][]) { + const clonedVault = + vault == null ? undefined : cloneVault(vault, this.mutableMarkets); + this.mutableVaults[address] = clonedVault; + } + + for (const [vault, allocations] of Object.entries( + input.allocations ?? {}, + ) as [ + Address, + Readonly> | undefined, + ][]) { + if (isClone) { + this.mutableAllocations[vault] = { ...allocations }; + continue; + } + this.mutableAllocations[vault] = {}; + for (const [id, allocation] of Object.entries(allocations ?? {}) as [ + Hash, + IVaultV2Allocation | undefined, + ][]) { + this.mutableAllocations[vault]![id] = + allocation == null ? undefined : { ...allocation }; + } + } + } + + /** + * Clones the complete simulation snapshot. + * + * @returns A deep clone of this simulation state. + * @example + * ```ts + * const next = data.clone(); + * ``` + */ + public clone() { + return new VaultV2BlueReallocationData(this); + } + + /** + * Gets a market from this snapshot. + * + * @param marketId - Market id to read. + * @returns The market state. + * @throws {UnknownReallocationMarketError} when the market is absent. + * @example + * ```ts + * const market = data.getMarket(marketId); + * ``` + */ + public getMarket(marketId: MarketId): ReadonlyMarketSnapshot { + const market = this.mutableMarkets[marketId]; + if (market == null) throw new UnknownReallocationMarketError(marketId); + return market; + } + + /** + * Gets a Vault V2 from this snapshot. + * + * @param vault - Vault V2 address. + * @returns The accrued Vault V2 state. + * @throws {UnknownReallocationVaultError} when the vault is absent. + * @example + * ```ts + * const vault = data.getVault(vaultAddress); + * ``` + */ + public getVault(vault: Address): ReadonlyVaultSnapshot { + return this.getMutableVault(vault); + } + + private getMutableVault(vault: Address) { + const key = findAddressKey(this.mutableVaults, vault); + const data = key == null ? undefined : this.mutableVaults[key]; + if (data == null) throw new UnknownReallocationVaultError(vault); + return data; + } + + /** + * Gets one Vault V2 allocation record. + * + * @param vault - Vault V2 address. + * @param id - Derived allocation id. + * @returns The allocation and cap state. + * @throws {UnknownReallocationAllocationError} when the record is absent. + * @example + * ```ts + * const allocation = data.getAllocation(vaultAddress, allocationId); + * ``` + */ + public getAllocation(vault: Address, id: Hash) { + const key = findAddressKey(this.allocations, vault); + const allocation = key == null ? undefined : this.allocations[key]?.[id]; + if (allocation == null) + throw new UnknownReallocationAllocationError(vault, id); + return allocation; + } + + /** + * Gets one vault-wide BluePublicAllocator configuration. + * + * @param vault - Vault V2 address. + * @returns The vault-wide allocator configuration. + * @throws {UnknownReallocationPublicAllocatorConfigError} when it is absent. + * @example + * ```ts + * const config = data.getPublicAllocatorConfig(vaultAddress); + * ``` + */ + public getPublicAllocatorConfig(vault: Address) { + const config = this.getOptionalPublicAllocatorConfig(vault); + if (config == null) + throw new UnknownReallocationPublicAllocatorConfigError(vault); + return config; + } + + /** + * Gets fetched allocator authorization state, including an explicit absent value. + * + * @param vault - Vault V2 address. + * @returns The allocator config, or `undefined` when the fetched vault has not authorized it. + * @throws {UnknownReallocationPublicAllocatorConfigError} when the fetch state is absent. + */ + private getOptionalPublicAllocatorConfig(vault: Address) { + const key = findAddressKey(this.publicAllocatorConfigs, vault); + if (key == null) + throw new UnknownReallocationPublicAllocatorConfigError(vault); + return this.publicAllocatorConfigs[key]; + } + + /** + * Gets the BluePublicAllocator-active adapters for a Vault V2. + * + * @param vault - Vault V2 address. + * @returns The active adapter addresses in their supplied casing, or an empty set when none are active. + * @throws {UnknownReallocationActiveAdaptersError} when the active-adapter state is absent. + * @example + * ```ts + * const activeAdapters = data.getActiveAdapters(vaultAddress); + * ``` + */ + public getActiveAdapters(vault: Address): ReadonlySet
{ + const key = findAddressKey(this.activeAdapters, vault); + const adapters = key == null ? undefined : this.activeAdapters[key]; + if (adapters == null) + throw new UnknownReallocationActiveAdaptersError(vault); + return adapters; + } + + /** + * Gets one adapter-market BluePublicAllocator configuration. + * + * @param vault - Vault V2 address. + * @param adapterMarketCapId - Adapter-scoped market cap id. + * @returns The allocator cap and permissions. + * @throws {UnknownReallocationMarketPublicAllocatorConfigError} when it is absent. + * @example + * ```ts + * const config = data.getMarketPublicAllocatorConfig(vaultAddress, adapterMarketCapId); + * ``` + */ + public getMarketPublicAllocatorConfig( + vault: Address, + adapterMarketCapId: Hash, + ) { + const key = findAddressKey(this.marketPublicAllocatorConfigs, vault); + const config = + key == null + ? undefined + : this.marketPublicAllocatorConfigs[key]?.[adapterMarketCapId]; + if (config == null) + throw new UnknownReallocationMarketPublicAllocatorConfigError( + vault, + adapterMarketCapId, + ); + return config; + } + + /** + * Gets a supported MorphoMarketV1AdapterV2 from a Vault V2. + * + * @param vault - Vault V2 address. + * @param adapter - Adapter address. + * @returns The accrued adapter state. + * @throws {UnknownReallocationAdapterError} when it is absent or unsupported. + * @example + * ```ts + * const adapter = data.getAdapter(vaultAddress, adapterAddress); + * ``` + */ + public getAdapter( + vault: Address, + adapter: Address, + ): ReadonlyMarketAdapterSnapshot { + return this.getMutableAdapter(vault, adapter); + } + + private getMutableAdapter(vault: Address, adapter: Address) { + const data = this.getMutableVault(vault).accrualAdapters.find( + (candidate): candidate is AccrualVaultV2MorphoMarketV1AdapterV2 => + candidate instanceof AccrualVaultV2MorphoMarketV1AdapterV2 && + isAddressEqual(candidate.address, adapter), + ); + if (data == null) throw new UnknownReallocationAdapterError(vault, adapter); + return data; + } + + /** + * Computes Vault V2 BluePublicAllocator calls available for a target market. + * + * Without `options.operation`, discovers every friendly call. With an + * operation, caps the calls to the amount required by that borrow or + * loan-asset withdrawal and falls back to 100% source utilization only when + * friendly liquidity cannot cover the absolute shortfall. Friendly source + * utilization defaults to 90% and is configurable through + * `options.maxWithdrawalUtilization`. Vaults whose configured penalty exceeds + * `options.maxPenalty` are ignored. By default, only zero-penalty vaults are + * considered. + * + * @param marketId - Target Blue market id. + * @param options - Optional discovery controls and operation to support. + * @returns Flat action-ready reallocations and their post-simulation state. + * @throws {NegativeInputError} when `maxWithdrawalUtilization` or `maxPenalty` is negative. + * @throws {InputExceedsMaxError} when `maxWithdrawalUtilization` or `maxPenalty` exceeds WAD. + * @throws {NonPositiveInputError} when the operation amount is not positive and planning is enabled. + * @throws {UnknownReallocationMarketError} when a required market is absent. + * @throws {UnknownReallocationVaultError} when configured vault state is absent. + * @throws {UnknownReallocationPublicAllocatorConfigError} when allocator authorization state is absent. + * @throws {UnknownReallocationActiveAdaptersError} when active-adapter state is absent for a vault. + * @throws {InsufficientSharedLiquidityError} when selected liquidity cannot cover the absolute shortfall. + * @throws {ReallocationWithdrawExceedsMarketSupplyError} when a withdraw exceeds market supply. + * @example + * ```ts + * import { VaultV2BlueReallocationData } from "@morpho-org/morpho-sdk/entities"; + * + * const data = new VaultV2BlueReallocationData(input); + * const discovery = data.computeVaultV2BlueReallocations(targetMarketId, { + * timestamp, + * }); + * const plan = data.computeVaultV2BlueReallocations(targetMarketId, { + * timestamp, + * operation: { type: "borrow", amount: 1_000_000n }, + * }); + * ``` + */ + public computeVaultV2BlueReallocations( + marketId: MarketId, + options: VaultV2BluePublicAllocatorOptions & { + readonly operation?: { + readonly type: "borrow" | "withdraw"; + readonly amount: bigint; + }; + } = {}, + ): { + readonly reallocations: readonly VaultV2BlueReallocation[]; + readonly data: VaultV2BlueReallocationData; + } { + if (options.enabled === false) return { reallocations: [], data: this }; + + const maxWithdrawalUtilization = resolveMaxWithdrawalUtilization( + options.maxWithdrawalUtilization, + ); + const maxPenalty = resolveMaxPenalty(options.maxPenalty); + const resolvedOptions = { ...options, maxPenalty }; + const operation = options.operation; + if (operation == null) + return this.computeVaultV2BlueReallocationsAtUtilization({ + marketId, + maxWithdrawalUtilization, + options: resolvedOptions, + }); + + const { amount, type } = operation; + if (amount <= 0n) throw new NonPositiveInputError("amount", amount); + + const timestamp = + options.timestamp == null + ? this.getLatestSnapshotTimestamp() + : BigInt(options.timestamp); + const normalizedOptions: VaultV2BluePublicAllocatorOptions = { + ...resolvedOptions, + timestamp, + reallocatableVaults: + options.reallocatableVaults == null + ? undefined + : [...options.reallocatableVaults], + }; + const market = this.getMarket(marketId).accrueInterest(timestamp); + if (type === "withdraw" && amount > market.totalSupplyAssets) { + throw new ReallocationWithdrawExceedsMarketSupplyError({ + marketId, + withdrawAmount: amount, + totalSupplyAssets: market.totalSupplyAssets, + }); + } + + const newTotalBorrowAssets = + type === "borrow" + ? market.totalBorrowAssets + amount + : market.totalBorrowAssets; + const newTotalSupplyAssets = + type === "withdraw" + ? market.totalSupplyAssets - amount + : market.totalSupplyAssets; + + if ( + MarketUtils.getUtilization({ + totalSupplyAssets: newTotalSupplyAssets, + totalBorrowAssets: newTotalBorrowAssets, + }) <= DEFAULT_SUPPLY_TARGET_UTILIZATION + ) + return { reallocations: [], data: this }; + + let requiredAssets = + MathLib.wDivUp(newTotalBorrowAssets, DEFAULT_SUPPLY_TARGET_UTILIZATION) - + newTotalSupplyAssets; + + const friendly = this.computeVaultV2BlueReallocationsAtUtilization({ + marketId, + maxWithdrawalUtilization, + options: normalizedOptions, + }); + const discovered = [...friendly.reallocations]; + const friendlyMarket = friendly.data.getMarket(marketId); + const friendlyBorrow = + type === "borrow" + ? friendlyMarket.totalBorrowAssets + amount + : friendlyMarket.totalBorrowAssets; + const friendlySupply = + type === "withdraw" + ? friendlyMarket.totalSupplyAssets - amount + : friendlyMarket.totalSupplyAssets; + + if (friendlyBorrow > friendlySupply) { + requiredAssets = newTotalBorrowAssets - newTotalSupplyAssets; + discovered.push( + ...friendly.data.computeVaultV2BlueReallocationsAtUtilization({ + marketId, + maxWithdrawalUtilization: MathLib.WAD, + options: normalizedOptions, + }).reallocations, + ); + } + + if (requiredAssets <= 0n) return { reallocations: [], data: this }; + + const absoluteShortfall = + newTotalBorrowAssets > newTotalSupplyAssets + ? newTotalBorrowAssets - newTotalSupplyAssets + : 0n; + const reallocations: VaultV2BlueReallocation[] = []; + let remainingRequiredAssets = requiredAssets; + + for (const reallocation of discovered) { + const assets = MathLib.min(reallocation.assets, remainingRequiredAssets); + if (assets <= 0n) continue; + + reallocations.push({ ...reallocation, assets }); + remainingRequiredAssets -= assets; + if (remainingRequiredAssets === 0n) break; + } + + const reallocatedAssets = requiredAssets - remainingRequiredAssets; + if (reallocatedAssets < absoluteShortfall) { + throw new InsufficientSharedLiquidityError({ + marketId, + shortfall: absoluteShortfall, + available: reallocatedAssets, + }); + } + + let data = this.clone(); + data.setMarkets( + Object.values(data.markets) + .filter( + (currentMarket): currentMarket is ReadonlyMarketSnapshot => + currentMarket != null, + ) + .map((currentMarket) => currentMarket.accrueInterest(timestamp)), + ); + for (const reallocation of reallocations) { + data = data.cloneWithPublicReallocation({ + reallocation, + targetMarketId: marketId, + timestamp, + }); + } + + return { reallocations, data }; + } + + private computeVaultV2BlueReallocationsAtUtilization({ + marketId, + maxWithdrawalUtilization, + options = {}, + }: { + readonly marketId: MarketId; + readonly maxWithdrawalUtilization: bigint; + readonly options?: VaultV2BluePublicAllocatorOptions; + }): { + readonly reallocations: readonly VaultV2BlueReallocation[]; + readonly data: VaultV2BlueReallocationData; + } { + if (options.enabled === false) return { reallocations: [], data: this }; + + this.getMarket(marketId); + const timestamp = + options.timestamp == null + ? this.getLatestSnapshotTimestamp() + : BigInt(options.timestamp); + let data = this.clone(); + data.setMarkets( + Object.values(data.markets) + .filter((market): market is ReadonlyMarketSnapshot => market != null) + .map((market) => market.accrueInterest(timestamp)), + ); + const reallocations: VaultV2BlueReallocation[] = []; + const configuredVaults = Object.keys(data.vaults) as Address[]; + const vaults = Array.from( + new Set( + [...(options.reallocatableVaults ?? configuredVaults)] + .map((vault) => findAddressKey(data.vaults, vault)) + .filter((vault): vault is Address => vault != null), + ), + ); + const normalizedMarketId = marketId.toLowerCase(); + const adapterIdsCache = new Map(); + + while (true) { + const candidates = vaults + .map((vaultAddress) => { + const targetMarket = data.getMarket(marketId); + const publicAllocatorConfig = + data.getOptionalPublicAllocatorConfig(vaultAddress); + if (publicAllocatorConfig == null) return; + const vault = data.getVault(vaultAddress); + if ( + !isAddressEqual(publicAllocatorConfig.vault, vaultAddress) || + publicAllocatorConfig.penalty > + (options.maxPenalty ?? DEFAULT_MAX_REALLOCATION_PENALTY) + ) + return; + const activeAdapters = [...data.getActiveAdapters(vaultAddress)]; + + const targetSupplyHeadroom = MathLib.zeroFloorSub( + MathLib.MAX_UINT_128, + targetMarket.totalSupplyAssets, + ); + const rawCandidates: VaultV2BlueReallocation[] = []; + + for (const adapter of vault.accrualAdapters) { + if (!(adapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2)) + continue; + if (!isAddressEqual(adapter.parentVault, vaultAddress)) continue; + if ( + !isAddressEqual(targetMarket.params.loanToken, vault.asset) || + !isAddressEqual(targetMarket.params.irm, adapter.adaptiveCurveIrm) + ) + continue; + + const targetContext = _try(() => { + const [adapterCapId, collateralCapId, adapterMarketCapId] = + getAdapterIds(adapterIdsCache, adapter, targetMarket); + const marketPublicAllocatorConfig = + data.getMarketPublicAllocatorConfig( + vaultAddress, + adapterMarketCapId, + ); + if ( + !isAddressEqual( + marketPublicAllocatorConfig.vault, + vaultAddress, + ) || + !isAddressEqual( + marketPublicAllocatorConfig.adapter, + adapter.address, + ) || + !activeAdapters.some((activeAdapter) => + isAddressEqual(activeAdapter, adapter.address), + ) + ) + return; + + const adapterCapAllocation = data.getAllocation( + vaultAddress, + adapterCapId, + ); + const collateralCapAllocation = data.getAllocation( + vaultAddress, + collateralCapId, + ); + const adapterMarketCapAllocation = data.getAllocation( + vaultAddress, + adapterMarketCapId, + ); + if ( + [ + adapterCapAllocation, + collateralCapAllocation, + adapterMarketCapAllocation, + ].some(({ absoluteCap }) => absoluteCap === 0n) + ) + return; + + const expectedSupplyAssets = targetMarket.toSupplyAssets( + adapter.supplyShares[marketId] ?? 0n, + ); + const untracked = MathLib.zeroFloorSub( + expectedSupplyAssets, + adapterMarketCapAllocation.allocation, + ); + + return { + adapter, + adapterMarketCapAllocation, + marketPublicAllocatorConfig, + untracked, + }; + }, UnknownDataError); + if (targetContext == null) continue; + + const allocatorHeadroom = + targetContext.marketPublicAllocatorConfig.getMaxIn( + targetContext.adapterMarketCapAllocation.allocation + + targetContext.untracked, + ); + + if (publicAllocatorConfig.canPullFromIdle) { + const assets = MathLib.min( + MathLib.MAX_UINT_128, + targetSupplyHeadroom, + allocatorHeadroom, + MathLib.zeroFloorSub( + vault.assetBalance, + data.donatedPenaltyAssets[vaultAddress] ?? 0n, + ), + ); + if (assets > 0n) { + rawCandidates.push({ + vault: vaultAddress, + from: { type: "idle" }, + to: { adapter: targetContext.adapter.address }, + assets, + penalty: publicAllocatorConfig.penalty, + }); + } + } + + for (const sourceAdapter of vault.accrualAdapters) { + if ( + !( + sourceAdapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2 + ) + ) + continue; + if (!isAddressEqual(sourceAdapter.parentVault, vaultAddress)) + continue; + if ( + !activeAdapters.some((activeAdapter) => + isAddressEqual(activeAdapter, sourceAdapter.address), + ) + ) + continue; + + for (const sourceMarketReference of sourceAdapter.markets) { + const sourceMarket = data.getMarket(sourceMarketReference.id); + if ( + !isAddressEqual(sourceMarket.params.loanToken, vault.asset) || + !isAddressEqual( + sourceMarket.params.irm, + sourceAdapter.adaptiveCurveIrm, + ) + ) + continue; + if (sourceMarket.id.toLowerCase() === normalizedMarketId) + continue; + + const candidate = _try(() => { + const sourceIds = getAdapterIds( + adapterIdsCache, + sourceAdapter, + sourceMarket, + ); + const [, , sourceAdapterMarketCapId] = sourceIds; + const sourceConfig = data.getMarketPublicAllocatorConfig( + vaultAddress, + sourceAdapterMarketCapId, + ); + if ( + !isAddressEqual(sourceConfig.vault, vaultAddress) || + !isAddressEqual( + sourceConfig.adapter, + sourceAdapter.address, + ) || + !sourceConfig.canPullFromMarket + ) + return; + + const sourceAllocations = sourceIds.map((id) => + data.getAllocation(vaultAddress, id), + ); + if ( + sourceAllocations.some( + ({ allocation }) => allocation === 0n, + ) + ) + return; + + const expectedSupplyAssets = sourceMarket.toSupplyAssets( + sourceAdapter.supplyShares[sourceMarket.id] ?? 0n, + ); + const assets = MathLib.min( + MathLib.MAX_UINT_128, + targetSupplyHeadroom, + allocatorHeadroom, + expectedSupplyAssets, + sourceMarket.getWithdrawToUtilization( + maxWithdrawalUtilization, + ), + ); + if (assets <= 0n) return; + + return { + vault: vaultAddress, + from: { + type: "market", + adapter: sourceAdapter.address, + marketParams: sourceMarket.params, + }, + to: { adapter: targetContext.adapter.address }, + assets, + penalty: publicAllocatorConfig.penalty, + } satisfies VaultV2BlueReallocation; + }, UnknownDataError); + if (candidate != null) rawCandidates.push(candidate); + } + } + } + + const capCompatibleCandidates: VaultV2BlueReallocation[] = []; + for (const reallocation of rawCandidates) { + // MorphoMarketV1AdapterV2 rejects supplies that mint fewer shares than assets. + if ( + targetMarket.toSupplyShares(reallocation.assets, "Down") < + reallocation.assets + ) + continue; + + // Cap fit is monotonic but not linear in assets: the amount changes + // penalty donations, firstTotalAssets, rounded shares, and possibly + // shared allocation IDs. Binary search finds the exact largest fit. + let lower = 0n; + let upper = reallocation.assets; + let probeUpper = true; + const reallocationAdapter = data.getAdapter( + reallocation.vault, + reallocation.to.adapter, + ); + const targetIds = getAdapterIds( + adapterIdsCache, + reallocationAdapter, + targetMarket, + ); + + while (lower < upper) { + const assets = probeUpper ? upper : (lower + upper + 1n) / 2n; + probeUpper = false; + const postState = _try( + () => + data.cloneWithPublicReallocation({ + reallocation: { ...reallocation, assets }, + targetMarketId: marketId, + timestamp: targetMarket.lastUpdate, + adapterIdsCache, + probe: true, + }), + ReallocationAllocationUnderflowError, + ReallocationAdapterSupplySharesUnderflowError, + ); + if (postState == null) { + upper = assets - 1n; + continue; + } + const postVault = postState.getVault(reallocation.vault); + // Vault V2 checks relative caps against the transient firstTotalAssets, + // which stays fixed after the vault's first allocation in a transaction. + const firstTotalAssets = + postState.firstTotalAssets[reallocation.vault] ?? + postVault._totalAssets; + const withinCaps = targetIds.every((id) => { + const allocation = postState.getAllocation( + reallocation.vault, + id, + ); + const capacity = VaultV2Utils.allocationHeadroom( + { ...allocation, allocation: 0n }, + firstTotalAssets, + ).value; + return ( + allocation.absoluteCap > 0n && + allocation.allocation <= capacity + ); + }); + + if (withinCaps) lower = assets; + else upper = assets - 1n; + } + + if (lower > 0n) + capCompatibleCandidates.push({ + ...reallocation, + assets: lower, + }); + } + + return capCompatibleCandidates.sort( + bigIntComparator(({ assets }) => assets, "desc"), + )[0]; + }) + .filter( + (candidate): candidate is VaultV2BlueReallocation => + candidate != null, + ) + .sort(bigIntComparator(({ assets }) => assets, "desc")); + + const largest = candidates[0]; + if (largest == null) return { reallocations, data }; + + reallocations.push(largest); + data = data.cloneWithPublicReallocation({ + reallocation: largest, + targetMarketId: marketId, + timestamp, + adapterIdsCache, + }); + } + } + + /** + * Sums friendly Vault V2 shared liquidity available to a target market. + * + * @param marketId - Target Blue market id. + * @param options - Optional timestamp, enable flag, vault allowlist, source utilization ceiling, and maximum penalty. + * @returns Reallocatable market and idle assets, or `0n` when none are available. + * @throws {NegativeInputError} when `maxWithdrawalUtilization` or `maxPenalty` is negative. + * @throws {InputExceedsMaxError} when `maxWithdrawalUtilization` or `maxPenalty` exceeds WAD. + * @throws {UnknownReallocationMarketError} when a required market is absent. + * @throws {UnknownReallocationVaultError} when configured vault state is absent. + * @throws {UnknownReallocationPublicAllocatorConfigError} when allocator authorization state is absent. + * @throws {UnknownReallocationActiveAdaptersError} when active-adapter state is absent for a vault. + * @example + * ```ts + * const liquidity = data.getPublicReallocationLiquidity(targetMarketId); + * ``` + */ + public getPublicReallocationLiquidity( + marketId: MarketId, + options?: VaultV2BluePublicAllocatorOptions, + ) { + if (options?.enabled === false) return 0n; + + const maxPenalty = resolveMaxPenalty(options?.maxPenalty); + + return this.computeVaultV2BlueReallocationsAtUtilization({ + marketId, + maxWithdrawalUtilization: resolveMaxWithdrawalUtilization( + options?.maxWithdrawalUtilization, + ), + options: { ...options, maxPenalty }, + }).reallocations.reduce((total, { assets }) => total + assets, 0n); + } + + /** + * Computes borrow liquidity to a target utilization, including friendly + * Vault V2 public reallocations. + * + * @param marketId - Target Blue market id. + * @param utilization - Desired utilization, scaled by WAD. Defaults to 90%. + * @param options - Optional timestamp, enable flag, vault allowlist, source utilization ceiling, and maximum penalty. + * @returns Borrowable assets while remaining at or below `utilization`. + * @throws {NegativeInputError} when `maxWithdrawalUtilization` or `maxPenalty` is negative. + * @throws {InputExceedsMaxError} when `maxWithdrawalUtilization` or `maxPenalty` exceeds WAD. + * @throws {UnknownReallocationMarketError} when a required market is absent. + * @throws {UnknownReallocationVaultError} when configured vault state is absent. + * @throws {UnknownReallocationPublicAllocatorConfigError} when allocator authorization state is absent. + * @throws {UnknownReallocationActiveAdaptersError} when active-adapter state is absent for a vault. + * @example + * ```ts + * const liquidity = data.getAvailableLiquidityToUtilization(targetMarketId); + * ``` + */ + // biome-ignore lint/complexity/useMaxParams: mirrors the existing V1 metric API + public getAvailableLiquidityToUtilization( + marketId: MarketId, + utilization: bigint = DEFAULT_SUPPLY_TARGET_UTILIZATION, + options?: VaultV2BluePublicAllocatorOptions, + ) { + const timestamp = + options?.timestamp == null + ? this.getLatestSnapshotTimestamp() + : BigInt(options.timestamp); + const market = this.getMarket(marketId).accrueInterest(timestamp); + if ( + options?.enabled === false || + DEFAULT_SUPPLY_TARGET_UTILIZATION > utilization + ) + return market.getBorrowToUtilization(utilization); + + const availableLiquidity = + this.computeVaultV2BlueReallocationsAtUtilization({ + marketId, + maxWithdrawalUtilization: resolveMaxWithdrawalUtilization( + options?.maxWithdrawalUtilization, + ), + options: { + ...options, + timestamp, + maxPenalty: resolveMaxPenalty(options?.maxPenalty), + }, + }).reallocations.reduce((total, { assets }) => total + assets, 0n); + return MarketUtils.getBorrowToUtilization( + { + totalSupplyAssets: market.totalSupplyAssets + availableLiquidity, + totalBorrowAssets: market.totalBorrowAssets, + }, + utilization, + ); + } + + private getLatestSnapshotTimestamp() { + let timestamp = 0n; + for (const market of Object.values(this.markets)) { + if (market != null) timestamp = MathLib.max(timestamp, market.lastUpdate); + } + for (const vault of Object.values(this.vaults)) { + if (vault != null) timestamp = MathLib.max(timestamp, vault.lastUpdate); + } + return timestamp; + } + + private cloneWithPublicReallocation({ + reallocation, + targetMarketId, + timestamp, + adapterIdsCache = new Map(), + probe = false, + }: { + readonly reallocation: VaultV2BlueReallocation; + readonly targetMarketId: MarketId; + readonly timestamp: bigint; + readonly adapterIdsCache?: Map; + readonly probe?: boolean; + }) { + const sourceVaultKey = findAddressKey( + this.mutableVaults, + reallocation.vault, + ); + const sourceAllocationsKey = findAddressKey( + this.mutableAllocations, + reallocation.vault, + ); + const data = probe + ? new VaultV2BlueReallocationData({ + chainId: this.chainId, + markets: { [targetMarketId]: this.getMarket(targetMarketId) }, + vaults: + sourceVaultKey == null + ? {} + : { [sourceVaultKey]: this.mutableVaults[sourceVaultKey] }, + allocations: + sourceAllocationsKey == null + ? {} + : { + [sourceAllocationsKey]: + this.mutableAllocations[sourceAllocationsKey], + }, + }) + : this.clone(); + if (probe) { + const sourceDonationKey = findAddressKey( + this.donatedPenaltyAssets, + reallocation.vault, + ); + if (sourceDonationKey != null) + data.donatedPenaltyAssets[sourceDonationKey] = + this.donatedPenaltyAssets[sourceDonationKey]!; + const sourceFirstTotalAssetsKey = findAddressKey( + this.firstTotalAssets, + reallocation.vault, + ); + if (sourceFirstTotalAssetsKey != null) + data.firstTotalAssets[sourceFirstTotalAssetsKey] = + this.firstTotalAssets[sourceFirstTotalAssetsKey]!; + } + + const vaultKey = + findAddressKey(data.mutableVaults, reallocation.vault) ?? + reallocation.vault; + const allocationsKey = + findAddressKey(data.mutableAllocations, reallocation.vault) ?? + reallocation.vault; + const donationKey = + findAddressKey(data.donatedPenaltyAssets, reallocation.vault) ?? vaultKey; + const firstTotalAssetsKey = + findAddressKey(data.firstTotalAssets, reallocation.vault) ?? vaultKey; + let vault = data.getMutableVault(vaultKey); + const targetMarket = data.getMarket(targetMarketId); + + const penaltyAssets = + VaultV2BluePublicAllocatorConfigUtils.getPenaltyAssets( + reallocation, + reallocation.assets, + ); + vault.assetBalance += penaltyAssets; + data.donatedPenaltyAssets[donationKey] = + (data.donatedPenaltyAssets[donationKey] ?? 0n) + penaltyAssets; + + if (reallocation.from.type === "market") { + const sourceAdapter = data.getMutableAdapter( + reallocation.vault, + reallocation.from.adapter, + ); + const sourceMarket = data.getMarket(reallocation.from.marketParams.id); + const sourceIds = getAdapterIds( + adapterIdsCache, + sourceAdapter, + sourceMarket, + ); + const [, , sourceAdapterMarketCapId] = sourceIds; + const currentSupplyShares = + sourceAdapter.supplyShares[sourceMarket.id] ?? 0n; + const withdrawal = sourceMarket.withdraw( + reallocation.assets, + 0n, + timestamp, + ); + if (withdrawal.shares > currentSupplyShares) { + throw new ReallocationAdapterSupplySharesUnderflowError({ + vault: reallocation.vault, + adapter: sourceAdapter.address, + marketId: sourceMarket.id, + supplyShares: currentSupplyShares, + withdrawnShares: withdrawal.shares, + }); + } + sourceAdapter.supplyShares[sourceMarket.id] = + currentSupplyShares - withdrawal.shares; + data.setMarket(withdrawal.market); + const sourceChange = + withdrawal.market.toSupplyAssets( + sourceAdapter.supplyShares[sourceMarket.id] ?? 0n, + ) - + data.getAllocation(reallocation.vault, sourceAdapterMarketCapId) + .allocation; + for (const id of sourceIds) { + const allocation = data.getAllocation(reallocation.vault, id); + const nextAllocation = allocation.allocation + sourceChange; + if (nextAllocation < 0n) { + throw new ReallocationAllocationUnderflowError({ + vault: reallocation.vault, + id, + allocation: allocation.allocation, + change: sourceChange, + }); + } + data.mutableAllocations[allocationsKey]![id] = { + ...allocation, + allocation: nextAllocation, + }; + } + vault.assetBalance += reallocation.assets; + } + + if (data.firstTotalAssets[firstTotalAssetsKey] == null) { + // Vault V2's transient firstTotalAssets tracks the first allocation in a + // transaction independently from elapsed time. Later allocations must not + // recompute the denominator, even if their simulated balances have changed. + if (timestamp === vault.lastUpdate) { + // AccrualVaultV2 skips zero-elapsed accruals, but the contract's first + // touch still reads real adapter assets. With zero elapsed time, its + // growth clamp leaves only existing losses to recognize. + const realAssets = vault.accrualAdapters.reduce( + (assets, adapter) => assets + adapter.realAssets(timestamp), + vault.assetBalance, + ); + vault._totalAssets = MathLib.min(realAssets, vault._totalAssets); + } else { + vault = vault.accrueInterest(timestamp).vault; + } + data.mutableVaults[vaultKey] = vault; + data.firstTotalAssets[firstTotalAssetsKey] = vault._totalAssets; + } + + const targetAdapter = data.getMutableAdapter( + reallocation.vault, + reallocation.to.adapter, + ); + const targetIds = getAdapterIds( + adapterIdsCache, + targetAdapter, + targetMarket, + ); + const [, , targetAdapterMarketCapId] = targetIds; + + const currentTargetMarket = data.getMarket(targetMarket.id); + const oldTargetAllocation = data.getAllocation( + reallocation.vault, + targetAdapterMarketCapId, + ).allocation; + const supply = currentTargetMarket.supply( + reallocation.assets, + 0n, + timestamp, + ); + const targetSupplyShares = + (targetAdapter.supplyShares[targetMarket.id] ?? 0n) + supply.shares; + targetAdapter.supplyShares[targetMarket.id] = targetSupplyShares; + if (!targetAdapter.marketIds.includes(targetMarket.id)) + targetAdapter.marketIds.push(targetMarket.id); + if (!targetAdapter.markets.some(({ id }) => id === targetMarket.id)) + targetAdapter.markets.push(supply.market); + data.setMarket(supply.market); + + const targetChange = + supply.market.toSupplyAssets(targetSupplyShares) - oldTargetAllocation; + for (const id of targetIds) { + const allocation = data.getAllocation(reallocation.vault, id); + const nextAllocation = allocation.allocation + targetChange; + if (nextAllocation < 0n) { + throw new ReallocationAllocationUnderflowError({ + vault: reallocation.vault, + id, + allocation: allocation.allocation, + change: targetChange, + }); + } + data.mutableAllocations[allocationsKey]![id] = { + ...allocation, + allocation: nextAllocation, + }; + } + + vault.assetBalance -= reallocation.assets; + return data; + } + + /** Updates one canonical market and its dependent adapter views. */ + private setMarket(market: Market) { + this.setMarkets([market]); + } + + /** Updates canonical markets in bulk and refreshes only dependent adapter views. */ + private setMarkets(markets: Iterable) { + const changedMarketIds = new Set(); + for (const market of markets) { + this.mutableMarkets[market.id] = market; + changedMarketIds.add(market.id); + } + if (changedMarketIds.size === 0) return; + + // A Morpho market is global state shared by every vault position. Legacy + // AccrualPosition constructors copy their Market, so rebuild those adapter + // views as well as repointing V2 adapters whenever the canonical state moves. + for (const vault of Object.values(this.mutableVaults)) { + if (vault == null) continue; + const adapters = new Set(vault.accrualAdapters); + if (vault.accrualLiquidityAdapter != null) + adapters.add(vault.accrualLiquidityAdapter); + + for (const adapter of adapters) { + if (adapter instanceof AccrualVaultV2MorphoMarketV1AdapterV2) { + if (!adapter.markets.some(({ id }) => changedMarketIds.has(id))) + continue; + adapter.markets = adapter.markets.map((adapterMarket) => + getCanonicalMarket(this.mutableMarkets, adapterMarket), + ); + } else if (adapter instanceof AccrualVaultV2MorphoMarketV1Adapter) { + if ( + !adapter.positions.some(({ marketId }) => + changedMarketIds.has(marketId), + ) + ) + continue; + adapter.positions = adapter.positions.map((position) => + clonePosition(position, this.mutableMarkets), + ); + } else if (adapter instanceof AccrualVaultV2MorphoVaultV1Adapter) { + if ( + ![...adapter.accrualVaultV1.allocations.keys()].some((marketId) => + changedMarketIds.has(marketId), + ) + ) + continue; + adapter.accrualVaultV1 = cloneAccrualVault( + adapter.accrualVaultV1, + this.mutableMarkets, + ); + } + } + } + } +} diff --git a/packages/morpho-sdk/src/helpers/AGENTS.md b/packages/morpho-sdk/src/helpers/AGENTS.md index 36494d266..900b630d2 100644 --- a/packages/morpho-sdk/src/helpers/AGENTS.md +++ b/packages/morpho-sdk/src/helpers/AGENTS.md @@ -9,7 +9,7 @@ Per-function contracts (arguments, return shapes, behavior) live as JSDoc on eac - **Encoders** (ABI encoding plus input validation, no I/O) — e.g. `encodeForceDeallocateCall(deallocation, onBehalf)`. ABI-encodes a single `VaultV2.forceDeallocate` calldata entry and throws `NonPositiveInputError` on a non-positive `amount`. The `data` field carries ABI-encoded `MarketParams` for the Morpho Market V1 adapter, or empty bytes otherwise. Internal sub-helpers (e.g. `encodeDeallocateData`) are not exported. - **Validators** (pure, throw typed errors) — `validateReallocations(...)`, `validateSlippageTolerance(...)`, `validatePositionHealth(...)`. Each enforces a public-API invariant: see the `error.ts` exports for the full list of error classes a caller may pattern-match on. - **Math / share-price helpers** — `computeMaxRepaySharePrice`, `computeMinBorrowSharePrice`, etc. Use `MAX_SLIPPAGE_TOLERANCE` and cap at `MAX_ABSOLUTE_SHARE_PRICE`. -- **Shared-liquidity** — `computeReallocations` builds PublicAllocator reallocations for a borrow/withdraw (friendly phase respecting withdrawal-utilization targets, then an aggressive 100% fallback). `getSupplyTargetUtilization(marketId, options)` resolves the per-market → default → `DEFAULT_SUPPLY_TARGET_UTILIZATION` supply target (shared by `computeReallocations` and the entity metric). The read-only liquidity metrics live on the `ReallocationData` entity (`getPublicReallocationLiquidity` / `getAvailableLiquidityToUtilization`), not in this layer. +- **Shared-liquidity** — `computeVaultV1Reallocations` builds PublicAllocator V1 reallocations for a borrow/withdraw; `computeReallocations` remains its deprecated compatibility alias. Vault V2 planning and state transitions live on `VaultV2BlueReallocationData`. `getSupplyTargetUtilization(marketId, options)` resolves the per-market → default → `DEFAULT_SUPPLY_TARGET_UTILIZATION` supply target for V1. Read-only liquidity metrics live on the corresponding versioned reallocation-data entity, not in this layer. - **Metadata** — `addTransactionMetadata(tx, metadata)` appends hex-encoded analytics bytes to `tx.data`: an optional 4-byte unix timestamp followed by a 4-byte origin (timestamp is omitted when `metadata.timestamp` is falsy). Callers gate on `metadata` being provided; the helper itself is a no-op when `tx.data` is empty. ## Constants diff --git a/packages/morpho-sdk/src/helpers/computeReallocations.test.ts b/packages/morpho-sdk/src/helpers/computeVaultV1Reallocations.test.ts similarity index 94% rename from packages/morpho-sdk/src/helpers/computeReallocations.test.ts rename to packages/morpho-sdk/src/helpers/computeVaultV1Reallocations.test.ts index eb4152844..c1a121b73 100644 --- a/packages/morpho-sdk/src/helpers/computeReallocations.test.ts +++ b/packages/morpho-sdk/src/helpers/computeVaultV1Reallocations.test.ts @@ -12,13 +12,16 @@ import { WethUsdsBlue, WstethUsdcSourceMarket, } from "../../test/fixtures/blue.js"; -import type { ReallocationData } from "../entities/reallocationData.js"; +import type { VaultV1ReallocationData } from "../entities/vaultV1ReallocationData.js"; import { InsufficientSharedLiquidityError, MissingPublicAllocatorConfigError, ReallocationWithdrawExceedsMarketSupplyError, } from "../types/index.js"; -import { computeReallocations } from "./computeReallocations.js"; +import { + computeReallocations, + computeVaultV1Reallocations, +} from "./computeVaultV1Reallocations.js"; // --- Constants --- @@ -84,10 +87,10 @@ interface MockStateParams { } /** - * Creates a minimal mock ReallocationData. + * Creates a minimal mock VaultV1ReallocationData. * - * Only implements the methods computeReallocations actually calls: - * `getMarket`, `getMarketPublicReallocations`, and `getVault`. + * Only implements the methods computeVaultV1Reallocations actually calls: + * `getMarket`, `computeVaultV1Reallocations`, and `getVault`. */ function makeMockState({ targetMarket: tm = defaultTarget, @@ -96,7 +99,7 @@ function makeMockState({ aggressiveWithdrawals = [], vaultFees = {}, extraMarkets = [], -}: MockStateParams = {}): ReallocationData { +}: MockStateParams = {}): VaultV1ReallocationData { const markets = new Map(); markets.set(tm.id, tm); markets.set(sourceA.id, sourceA); @@ -109,9 +112,9 @@ function makeMockState({ id === tm.id && friendlyTargetMarket != null ? friendlyTargetMarket : markets.get(id)!, - getMarketPublicReallocations: () => ({ + computeVaultV1Reallocations: () => ({ withdrawals: [...aggressiveWithdrawals], - data: {} as ReallocationData, + data: {} as VaultV1ReallocationData, }), }; @@ -121,7 +124,7 @@ function makeMockState({ if (m == null) throw new Error(`Mock: unknown market ${id}`); return m; }, - getMarketPublicReallocations: () => ({ + computeVaultV1Reallocations: () => ({ withdrawals: [...friendlyWithdrawals], data: friendlyData, }), @@ -130,18 +133,22 @@ function makeMockState({ ? { admin: vault, fee: vaultFees[vault]!, accruedFee: 0n } : undefined, }), - } as unknown as ReallocationData; + } as unknown as VaultV1ReallocationData; } // --------------------------------------------------------------------------- // Early returns // --------------------------------------------------------------------------- -describe("computeReallocations", () => { +describe("computeVaultV1Reallocations", () => { + test("behavior: preserves the deprecated planner alias", () => { + expect(computeReallocations).toBe(computeVaultV1Reallocations); + }); + describe("early returns", () => { test("should return empty when enabled is false", () => { - const result = computeReallocations({ - reallocationData: {} as ReallocationData, + const result = computeVaultV1Reallocations({ + reallocationData: {} as VaultV1ReallocationData, marketId: targetParams.id, operation: "borrow", amount: MathLib.WAD, @@ -153,7 +160,7 @@ describe("computeReallocations", () => { test("should return empty when post-borrow utilization is below supply target", () => { const data = makeMockState(); // Borrow 1 WAD: utilization ≈ 501/1000 = 50.1% < 90.5% - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -182,7 +189,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 1000n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -218,7 +225,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 1000n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -256,7 +263,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -293,7 +300,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -345,7 +352,7 @@ describe("computeReallocations", () => { ], }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -390,7 +397,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 500n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -436,7 +443,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -481,7 +488,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 1000n, [VAULT_B]: 2000n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -520,7 +527,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 1000n, [VAULT_B]: 2000n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -554,7 +561,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 1000n, [VAULT_B]: 2000n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -607,7 +614,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -640,7 +647,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -684,7 +691,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -701,7 +708,7 @@ describe("computeReallocations", () => { }); test("should return empty when required assets is non-positive", () => { - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: makeMockState(), marketId: targetParams.id, operation: "borrow", @@ -730,7 +737,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -762,7 +769,7 @@ describe("computeReallocations", () => { // Set target utilization very high (99%) — requires less reallocation. const highTarget = (99n * MathLib.WAD) / 100n; - const resultHigh = computeReallocations({ + const resultHigh = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -775,7 +782,7 @@ describe("computeReallocations", () => { // Set target utilization low (50%) — requires more reallocation. const lowTarget = MathLib.WAD / 2n; - const resultLow = computeReallocations({ + const resultLow = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -818,7 +825,7 @@ describe("computeReallocations", () => { }); try { - computeReallocations({ + computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -861,7 +868,7 @@ describe("computeReallocations", () => { }); try { - computeReallocations({ + computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -893,7 +900,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -923,7 +930,7 @@ describe("computeReallocations", () => { }); expect(() => - computeReallocations({ + computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "borrow", @@ -942,7 +949,7 @@ describe("computeReallocations", () => { test("should return empty when post-withdraw utilization is below supply target", () => { // Default market: S=1000, B=500. Withdraw 100 → S'=900, util = 500/900 ≈ 55.5% < 90.5%. const data = makeMockState(); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "withdraw", @@ -970,7 +977,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "withdraw", @@ -1009,7 +1016,7 @@ describe("computeReallocations", () => { vaultFees: { [VAULT_A]: 0n }, }); - const result = computeReallocations({ + const result = computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "withdraw", @@ -1054,7 +1061,7 @@ describe("computeReallocations", () => { }); expect(() => - computeReallocations({ + computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "withdraw", @@ -1072,7 +1079,7 @@ describe("computeReallocations", () => { const withdrawAmount = parseEther("1001"); try { - computeReallocations({ + computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "withdraw", @@ -1099,7 +1106,7 @@ describe("computeReallocations", () => { }); const data = makeMockState({ targetMarket: tm }); expect(() => - computeReallocations({ + computeVaultV1Reallocations({ reallocationData: data, marketId: targetParams.id, operation: "withdraw", diff --git a/packages/morpho-sdk/src/helpers/computeReallocations.ts b/packages/morpho-sdk/src/helpers/computeVaultV1Reallocations.ts similarity index 89% rename from packages/morpho-sdk/src/helpers/computeReallocations.ts rename to packages/morpho-sdk/src/helpers/computeVaultV1Reallocations.ts index 9603b6440..68755480e 100644 --- a/packages/morpho-sdk/src/helpers/computeReallocations.ts +++ b/packages/morpho-sdk/src/helpers/computeVaultV1Reallocations.ts @@ -1,13 +1,13 @@ import { type MarketId, MarketUtils, MathLib } from "@morpho-org/blue-sdk"; import type { Address } from "viem"; -import type { ReallocationData } from "../entities/reallocationData.js"; +import type { VaultV1ReallocationData } from "../entities/vaultV1ReallocationData.js"; import { InsufficientSharedLiquidityError, MissingPublicAllocatorConfigError, type PublicReallocation, type ReallocationComputeOptions, ReallocationWithdrawExceedsMarketSupplyError, - type VaultReallocation, + type VaultV1Reallocation, } from "../types/index.js"; import { getSupplyTargetUtilization } from "./utilization.js"; import { compareMarketIds } from "./validate.js"; @@ -113,7 +113,7 @@ const capVaultWithdrawals = ( * import { mainnet } from "viem/chains"; * import { markets, vaults } from "@morpho-org/morpho-test"; * import { - * computeReallocations, + * computeVaultV1Reallocations, * morphoViemExtension, * } from "@morpho-org/morpho-sdk"; * @@ -126,12 +126,12 @@ const capVaultWithdrawals = ( * const marketParams = markets[mainnet.id].usdc_wbtc; * const market = client.morpho.blue(marketParams, mainnet.id); * const block = await client.getBlock(); - * const reallocationData = await market.getReallocationData({ + * const reallocationData = await market.getVaultV1ReallocationData({ * vaultAddresses: [vaults[mainnet.id].steakUsdc.address], * block: { number: block.number, timestamp: block.timestamp }, * }); * const borrowAmount = parseUnits("1000", 6); - * const reallocations = computeReallocations({ + * const reallocations = computeVaultV1Reallocations({ * reallocationData, * marketId: marketParams.id, * operation: "borrow", @@ -148,24 +148,33 @@ const capVaultWithdrawals = ( * // borrow.buildTx() includes any required PublicAllocator reallocations. * ``` */ -export const computeReallocations = ({ +export const computeVaultV1Reallocations = ({ reallocationData: data, marketId, operation, amount, options, }: { - readonly reallocationData: ReallocationData; + readonly reallocationData: VaultV1ReallocationData; readonly marketId: MarketId; readonly operation: "borrow" | "withdraw"; readonly amount: bigint; readonly options?: ReallocationComputeOptions; -}): readonly VaultReallocation[] => { +}): readonly VaultV1Reallocation[] => { if (options?.enabled === false) return []; - - // ReallocationData does not retain the fetch block; pass that block timestamp + const normalizedOptions = { + ...options, + reallocatableVaults: + options?.reallocatableVaults == null + ? undefined + : [...options.reallocatableVaults], + }; + + // VaultV1ReallocationData does not retain the fetch block; pass that block timestamp // to compute against the same accrued state, otherwise Market defaults to lastUpdate. - const market = data.getMarket(marketId).accrueInterest(options?.timestamp); + const market = data + .getMarket(marketId) + .accrueInterest(normalizedOptions.timestamp); // Reject unreachable withdraws before any utilization math: a negative // post-supply yields a negative utilization that short-circuits the @@ -190,7 +199,7 @@ export const computeReallocations = ({ const supplyTargetUtilization = getSupplyTargetUtilization( market.params.id, - options, + normalizedOptions, ); if ( @@ -210,7 +219,7 @@ export const computeReallocations = ({ // Phase 1: "friendly" reallocations respecting withdrawal utilization targets. const { withdrawals: friendlyWithdrawals, data: friendlyReallocationData } = - data.getMarketPublicReallocations(market.id, options); + data.computeVaultV1Reallocations(market.id, normalizedOptions); const withdrawals = [...friendlyWithdrawals]; @@ -232,8 +241,8 @@ export const computeReallocations = ({ // Phase 2: "aggressive" — fully withdraw from every market (100% utilization). requiredAssets = newTotalBorrowAssets - newTotalSupplyAssets; withdrawals.push( - ...friendlyReallocationData.getMarketPublicReallocations(market.id, { - ...options, + ...friendlyReallocationData.computeVaultV1Reallocations(market.id, { + ...normalizedOptions, defaultMaxWithdrawalUtilization: MathLib.WAD, maxWithdrawalUtilization: {}, }).withdrawals, @@ -283,7 +292,7 @@ export const computeReallocations = ({ }); } - // Transform into VaultReallocation[] format. + // Transform into VaultV1Reallocation[] format. return reallocations .filter(({ withdrawals: vaultWithdrawals }) => vaultWithdrawals.length > 0) .map(({ vault, withdrawals: vaultWithdrawals }) => ({ @@ -305,3 +314,10 @@ export const computeReallocations = ({ })), })); }; + +/** + * Deprecated name for the Vault V1 amount-aware reallocation planner. + * + * @deprecated Use {@link computeVaultV1Reallocations} instead. + */ +export const computeReallocations = computeVaultV1Reallocations; diff --git a/packages/morpho-sdk/src/helpers/constant.test.ts b/packages/morpho-sdk/src/helpers/constant.test.ts index 053bac37a..eae84baaa 100644 --- a/packages/morpho-sdk/src/helpers/constant.test.ts +++ b/packages/morpho-sdk/src/helpers/constant.test.ts @@ -2,7 +2,9 @@ import { MathLib } from "@morpho-org/blue-sdk"; import { describe, expect, test } from "vitest"; import { DEFAULT_LLTV_BUFFER, + DEFAULT_MAX_REALLOCATION_PENALTY, MAX_ABSOLUTE_SHARE_PRICE, + MAX_REALLOCATION_PENALTY, MAX_SLIPPAGE_TOLERANCE, } from "./constant.js"; @@ -21,6 +23,11 @@ describe("morpho-sdk helper constants", () => { expect(MAX_ABSOLUTE_SHARE_PRICE).toBe(100n * MathLib.RAY); }); + test("Vault V2 reallocation penalties default to zero and cap at WAD", () => { + expect(DEFAULT_MAX_REALLOCATION_PENALTY).toBe(0n); + expect(MAX_REALLOCATION_PENALTY).toBe(MathLib.WAD); + }); + test("constants are positive bigints", () => { expect(typeof MAX_SLIPPAGE_TOLERANCE).toBe("bigint"); expect(typeof DEFAULT_LLTV_BUFFER).toBe("bigint"); diff --git a/packages/morpho-sdk/src/helpers/constant.ts b/packages/morpho-sdk/src/helpers/constant.ts index 36b41b20d..c0bf35b0e 100644 --- a/packages/morpho-sdk/src/helpers/constant.ts +++ b/packages/morpho-sdk/src/helpers/constant.ts @@ -4,6 +4,12 @@ import { type Address, maxUint96 } from "viem"; /** Maximum slippage tolerance: 10% */ export const MAX_SLIPPAGE_TOLERANCE = MathLib.WAD / 10n; +/** Default maximum Vault V2 reallocation penalty: zero. */ +export const DEFAULT_MAX_REALLOCATION_PENALTY = 0n; + +/** Maximum Vault V2 reallocation penalty: 100%. */ +export const MAX_REALLOCATION_PENALTY = MathLib.WAD; + /** Default LLTV buffer: 0.5% below LLTV. Prevents instant liquidation on new positions. */ export const DEFAULT_LLTV_BUFFER = MathLib.WAD / 200n; @@ -12,9 +18,7 @@ export const MAX_ABSOLUTE_SHARE_PRICE = 100n * MathLib.RAY; /** * The default maximum utilization a source market may reach when withdrawing - * shared liquidity, scaled by WAD. Still overridable through the deprecated - * `maxWithdrawalUtilization` / `defaultMaxWithdrawalUtilization` options until - * the next major. + * shared liquidity, scaled by WAD. */ export const DEFAULT_WITHDRAWAL_TARGET_UTILIZATION = 90_0000000000000000n; diff --git a/packages/morpho-sdk/src/helpers/index.ts b/packages/morpho-sdk/src/helpers/index.ts index 7545cb273..6a723be2b 100644 --- a/packages/morpho-sdk/src/helpers/index.ts +++ b/packages/morpho-sdk/src/helpers/index.ts @@ -1,10 +1,15 @@ -export { computeReallocations } from "./computeReallocations.js"; +export { + computeReallocations, + computeVaultV1Reallocations, +} from "./computeVaultV1Reallocations.js"; export { APPROVE_ONLY_ONCE_TOKENS, DEFAULT_LLTV_BUFFER, + DEFAULT_MAX_REALLOCATION_PENALTY, DEFAULT_SUPPLY_TARGET_UTILIZATION, DEFAULT_WITHDRAWAL_TARGET_UTILIZATION, MAX_ABSOLUTE_SHARE_PRICE, + MAX_REALLOCATION_PENALTY, MAX_SLIPPAGE_TOLERANCE, MAX_TOKEN_APPROVALS, } from "./constant.js"; diff --git a/packages/morpho-sdk/src/helpers/validate.test.ts b/packages/morpho-sdk/src/helpers/validate.test.ts index 89032b49d..1c134f0ad 100644 --- a/packages/morpho-sdk/src/helpers/validate.test.ts +++ b/packages/morpho-sdk/src/helpers/validate.test.ts @@ -7,7 +7,7 @@ import { MathLib, ORACLE_PRICE_SCALE, } from "@morpho-org/blue-sdk"; -import type { Address } from "viem"; +import { type Address, maxUint128 } from "viem"; import { mainnet } from "viem/chains"; import { describe, expect, test } from "vitest"; import { CbbtcUsdcBlue, WethUsdsBlue } from "../../test/fixtures/blue.js"; @@ -18,11 +18,17 @@ import { import { AccrualPositionUserMismatchError, AddressMismatchError, + type BlueReallocationPlan, BorrowExceedsSafeLtvError, ChainIdMismatchError, ChainWNativeMissingError, EmptyReallocationWithdrawalsError, ExcessiveSlippageToleranceError, + InconsistentReallocationPenaltyError, + InputExceedsMaxError, + InvalidReallocationAddressError, + InvalidReallocationShapeError, + InvalidReallocationSourceTypeError, MarketIdMismatchError, MissingClientPropertyError, MissingMarketPriceError, @@ -34,14 +40,19 @@ import { RepaySharesExceedDebtError, UnsortedReallocationWithdrawalsError, type VaultReallocation, + type VaultV2BlueReallocation, WithdrawExceedsCollateralError, WithdrawExceedsSupplyError, WithdrawMakesPositionUnhealthyError, WithdrawSharesExceedSupplyError, } from "../types/index.js"; -import { MAX_SLIPPAGE_TOLERANCE } from "./constant.js"; +import { + MAX_REALLOCATION_PENALTY, + MAX_SLIPPAGE_TOLERANCE, +} from "./constant.js"; import { validateAccrualPosition, + validateAndNormalizeReallocations, validateChainId, validateMidnightMarketChainId, validateNativeAsset, @@ -52,6 +63,7 @@ import { validateRepayShares, validateSlippageTolerance, validateUserAddress, + validateVaultV2BlueReallocations, validateWithdrawAmount, validateWithdrawShares, } from "./validate.js"; @@ -540,7 +552,7 @@ describe("validateRepayShares", () => { // validateReallocations // --------------------------------------------------------------------------- -describe("validateReallocations", () => { +describe("reallocation validation", () => { const targetMarketId = marketParams.id; const sourceMarketA = new MarketParams(CbbtcUsdcBlue); const marketParamsWithId = (id: MarketId) => ({ @@ -554,12 +566,281 @@ describe("validateReallocations", () => { withdrawals: [{ marketParams: sourceMarketA, amount: 10n ** 18n }], }; + const validBluePublicAllocatorReallocation: VaultV2BlueReallocation = { + vault: USER_B, + from: { type: "idle" }, + to: { adapter: USER_A }, + assets: 1n, + penalty: 0n, + }; + test("should pass with valid reallocations", () => { expect(() => validateReallocations([validReallocation], targetMarketId), ).not.toThrow(); }); + test("behavior: accepts a valid Blue Public Allocator idle reallocation", () => { + expect(() => + validateVaultV2BlueReallocations( + [validBluePublicAllocatorReallocation], + targetMarketId, + ), + ).not.toThrow(); + }); + + test.each([ + { + name: "negative penalty", + reallocation: { + ...validBluePublicAllocatorReallocation, + penalty: -1n, + }, + ErrorClass: NegativeInputError, + }, + { + name: "penalty above the SDK maximum", + reallocation: { + ...validBluePublicAllocatorReallocation, + penalty: MAX_REALLOCATION_PENALTY + 1n, + }, + ErrorClass: InputExceedsMaxError, + }, + { + name: "zero assets", + reallocation: { ...validBluePublicAllocatorReallocation, assets: 0n }, + ErrorClass: NonPositiveInputError, + }, + { + name: "negative assets", + reallocation: { ...validBluePublicAllocatorReallocation, assets: -1n }, + ErrorClass: NonPositiveInputError, + }, + { + name: "uint128 asset overflow", + reallocation: { + ...validBluePublicAllocatorReallocation, + assets: maxUint128 + 1n, + }, + ErrorClass: InputExceedsMaxError, + }, + ])( + "error: rejects Blue Public Allocator $name", + ({ reallocation, ErrorClass }) => { + expect(() => + validateVaultV2BlueReallocations([reallocation], targetMarketId), + ).toThrow(ErrorClass); + }, + ); + + test("error: InconsistentReallocationPenaltyError for one vault", () => { + expect(() => + validateVaultV2BlueReallocations( + [ + { ...validBluePublicAllocatorReallocation, penalty: 5n }, + { ...validBluePublicAllocatorReallocation, penalty: 11n }, + ], + targetMarketId, + ), + ).toThrow(InconsistentReallocationPenaltyError); + }); + + test("behavior: accepts the maximum uint128 asset amount", () => { + expect(() => + validateVaultV2BlueReallocations( + [ + { + ...validBluePublicAllocatorReallocation, + assets: maxUint128, + }, + ], + targetMarketId, + ), + ).not.toThrow(); + }); + + test("behavior: accepts the maximum reallocation penalty", () => { + expect(() => + validateVaultV2BlueReallocations( + [ + { + ...validBluePublicAllocatorReallocation, + penalty: MAX_REALLOCATION_PENALTY, + }, + ], + targetMarketId, + ), + ).not.toThrow(); + }); + + test("behavior: allows different penalties for different vaults", () => { + expect(() => + validateVaultV2BlueReallocations( + [ + { ...validBluePublicAllocatorReallocation, penalty: 5n }, + { + ...validBluePublicAllocatorReallocation, + vault: USER_A, + penalty: 11n, + }, + ], + targetMarketId, + ), + ).not.toThrow(); + }); + + test("error: ReallocationWithdrawalOnTargetMarketError for a Blue Public Allocator target-market source", () => { + expect(() => + validateVaultV2BlueReallocations( + [ + { + ...validBluePublicAllocatorReallocation, + from: { + type: "market", + adapter: USER_A, + marketParams, + }, + } satisfies VaultV2BlueReallocation, + ], + targetMarketId, + ), + ).toThrow(ReallocationWithdrawalOnTargetMarketError); + }); + + test("error: target market through a different Vault V2 adapter", () => { + expect(() => + validateVaultV2BlueReallocations( + [ + { + ...validBluePublicAllocatorReallocation, + from: { + type: "market", + adapter: USER_B, + marketParams, + }, + } satisfies VaultV2BlueReallocation, + ], + targetMarketId, + ), + ).toThrow(ReallocationWithdrawalOnTargetMarketError); + }); + + test("error: target market supplied as plain market params", () => { + const plainMarketParams = { + loanToken: marketParams.loanToken, + collateralToken: marketParams.collateralToken, + oracle: marketParams.oracle, + irm: marketParams.irm, + lltv: marketParams.lltv, + }; + const reallocation = { + ...validBluePublicAllocatorReallocation, + from: { + type: "market", + adapter: USER_A, + marketParams: plainMarketParams, + }, + } as unknown as VaultV2BlueReallocation; + + expect(() => + validateVaultV2BlueReallocations([reallocation], targetMarketId), + ).toThrow(ReallocationWithdrawalOnTargetMarketError); + }); + + test.each([ + { name: "invalid vault", overrides: { vault: "not-an-address" } }, + { name: "missing target", overrides: { to: undefined } }, + { + name: "invalid target adapter", + overrides: { to: { adapter: "not-an-address" } }, + }, + ])("error: InvalidReallocationAddressError for $name", ({ overrides }) => { + const reallocation = { + ...validBluePublicAllocatorReallocation, + ...overrides, + } as unknown as VaultV2BlueReallocation; + + expect(() => + validateVaultV2BlueReallocations([reallocation], targetMarketId), + ).toThrow(InvalidReallocationAddressError); + }); + + test("error: InvalidReallocationSourceTypeError", () => { + const reallocation = { + ...validBluePublicAllocatorReallocation, + from: { type: "marketTypo" }, + } as unknown as VaultV2BlueReallocation; + + expect(() => + validateVaultV2BlueReallocations([reallocation], targetMarketId), + ).toThrow(InvalidReallocationSourceTypeError); + }); + + test.each([ + { name: "missing source", from: undefined }, + { name: "null source", from: null }, + { + name: "missing market params", + from: { type: "market", adapter: USER_A }, + }, + ])("error: InvalidReallocationSourceTypeError for $name", ({ from }) => { + const reallocation = { + ...validBluePublicAllocatorReallocation, + from, + } as unknown as VaultV2BlueReallocation; + + expect(() => + validateVaultV2BlueReallocations([reallocation], targetMarketId), + ).toThrow(InvalidReallocationSourceTypeError); + }); + + test("error: InvalidReallocationAddressError for missing source adapter", () => { + const reallocation = { + ...validBluePublicAllocatorReallocation, + from: { type: "market", marketParams: sourceMarketA }, + } as unknown as VaultV2BlueReallocation; + + expect(() => + validateVaultV2BlueReallocations([reallocation], targetMarketId), + ).toThrow(InvalidReallocationAddressError); + }); + + test.each([ + { + name: "entry matching neither shape", + reallocation: { + vault: USER_A, + fee: 0n, + } as unknown as VaultV2BlueReallocation, + }, + { + name: "entry matching both shapes", + reallocation: { + ...validReallocation, + from: { type: "idle" }, + to: { adapter: USER_A }, + assets: 1n, + penalty: 0n, + } as unknown as VaultV2BlueReallocation, + }, + { + name: "null entry", + reallocation: null as unknown as VaultV2BlueReallocation, + }, + { + name: "primitive entry", + reallocation: 1 as unknown as VaultV2BlueReallocation, + }, + ])("error: InvalidReallocationShapeError for $name", ({ reallocation }) => { + expect(() => + validateAndNormalizeReallocations({ + reallocations: [reallocation] as unknown as BlueReallocationPlan, + targetMarketId, + chainId: mainnet.id, + }), + ).toThrow(InvalidReallocationShapeError); + }); + test("should throw NegativeInputError when fee is negative", () => { expect(() => validateReallocations( diff --git a/packages/morpho-sdk/src/helpers/validate.ts b/packages/morpho-sdk/src/helpers/validate.ts index 4d024de11..34837581e 100644 --- a/packages/morpho-sdk/src/helpers/validate.ts +++ b/packages/morpho-sdk/src/helpers/validate.ts @@ -2,23 +2,32 @@ import { type AccrualPosition, getChainAddresses, type MarketId, + MarketUtils, MathLib, ORACLE_PRICE_SCALE, } from "@morpho-org/blue-sdk"; import type { MarketInput as MidnightMarketInput } from "@morpho-org/midnight-sdk"; import { isDefined } from "@morpho-org/morpho-ts"; -import { type Address, isAddressEqual } from "viem"; +import { type Address, isAddress, isAddressEqual, maxUint128 } from "viem"; import { AccrualPositionUserMismatchError, AddressMismatchError, + type BlueReallocationPlan, BorrowExceedsSafeLtvError, + BundlerErrors, ChainIdMismatchError, ChainWNativeMissingError, EmptyReallocationWithdrawalsError, ExcessiveSlippageToleranceError, + InconsistentReallocationPenaltyError, + InputExceedsMaxError, + InvalidReallocationAddressError, + InvalidReallocationShapeError, + InvalidReallocationSourceTypeError, MarketIdMismatchError, MissingClientPropertyError, MissingMarketPriceError, + MixedReallocationVersionsError, NativeAmountOnNonWNativeAssetError, NegativeInputError, NonPositiveInputError, @@ -26,13 +35,18 @@ import { RepayExceedsDebtError, RepaySharesExceedDebtError, UnsortedReallocationWithdrawalsError, - type VaultReallocation, + type VaultV1Reallocation, + type VaultV2BlueReallocation, WithdrawExceedsCollateralError, WithdrawExceedsSupplyError, WithdrawMakesPositionUnhealthyError, WithdrawSharesExceedSupplyError, } from "../types/index.js"; -import { DEFAULT_LLTV_BUFFER, MAX_SLIPPAGE_TOLERANCE } from "./constant.js"; +import { + DEFAULT_LLTV_BUFFER, + MAX_REALLOCATION_PENALTY, + MAX_SLIPPAGE_TOLERANCE, +} from "./constant.js"; /** @internal */ export const compareMarketIds = (idA: MarketId, idB: MarketId) => { @@ -323,18 +337,10 @@ export const validateRepayShares = (params: { }; /** - * Validates that vault reallocations are well-formed. - * - * Enforces the following invariants for each {@link VaultReallocation}: - * - `fee` must be non-negative. - * - `withdrawals` must be non-empty. - * - Every withdrawal `amount` must be strictly positive. - * - No withdrawal may target `targetMarketId` (the operation's target market — the market being - * borrowed from for `borrow`, or being withdrawn from for `withdraw`). - * - Withdrawal market IDs must be strictly ascending (required by `PublicAllocator.reallocateTo`). + * Validates that Vault V1 PublicAllocator reallocations are well-formed. * - * @param reallocations - The reallocations to validate. - * @param targetMarketId - The ID of the operation's target market. No withdrawal may reference this market. + * @param reallocations - Vault V1 reallocations to validate. + * @param targetMarketId - The operation's target market ID. * @returns Nothing when every reallocation is valid. * @throws {NegativeInputError} when a reallocation fee is negative. * @throws {EmptyReallocationWithdrawalsError} when a reallocation has no withdrawals. @@ -351,44 +357,204 @@ export const validateRepayShares = (params: { * ``` */ export const validateReallocations = ( - reallocations: readonly VaultReallocation[], + reallocations: Iterable, targetMarketId: MarketId, ): void => { - for (const r of reallocations) { - if (r.fee < 0n) { - throw new NegativeInputError("reallocation.fee", r.fee); + for (const reallocation of reallocations) { + if (reallocation.fee < 0n) { + throw new NegativeInputError("reallocation.fee", reallocation.fee); } - if (r.withdrawals.length === 0) { - throw new EmptyReallocationWithdrawalsError(r.vault); + if (reallocation.withdrawals.length === 0) { + throw new EmptyReallocationWithdrawalsError(reallocation.vault); } - let prevId: MarketId | undefined; - for (const w of r.withdrawals) { - if (w.amount <= 0n) { + let previousMarketId: MarketId | undefined; + for (const withdrawal of reallocation.withdrawals) { + if (withdrawal.amount <= 0n) { throw new NonPositiveInputError( - `reallocation.withdrawals[${w.marketParams.id}].amount`, - w.amount, + `reallocation.withdrawals[${withdrawal.marketParams.id}].amount`, + withdrawal.amount, ); } - if (w.marketParams.id === targetMarketId) { + if (withdrawal.marketParams.id === targetMarketId) { throw new ReallocationWithdrawalOnTargetMarketError( - r.vault, - w.marketParams.id, + reallocation.vault, + withdrawal.marketParams.id, ); } if ( - prevId !== undefined && - compareMarketIds(w.marketParams.id, prevId) <= 0 + previousMarketId !== undefined && + compareMarketIds(withdrawal.marketParams.id, previousMarketId) <= 0 ) { throw new UnsortedReallocationWithdrawalsError( - r.vault, - w.marketParams.id, + reallocation.vault, + withdrawal.marketParams.id, ); } - prevId = w.marketParams.id; + previousMarketId = withdrawal.marketParams.id; } } }; +/** @internal */ +export const validateVaultV2BlueReallocations = ( + reallocations: Iterable, + targetMarketId: MarketId, +): void => { + const penaltyByVault = new Map(); + + for (const reallocation of reallocations) { + if ( + typeof reallocation.vault !== "string" || + !isAddress(reallocation.vault) + ) { + throw new InvalidReallocationAddressError("vault"); + } + if ( + reallocation.to == null || + typeof reallocation.to.adapter !== "string" || + !isAddress(reallocation.to.adapter) + ) { + throw new InvalidReallocationAddressError("to.adapter"); + } + + const source = reallocation.from; + if (source == null) { + throw new InvalidReallocationSourceTypeError(undefined); + } + const sourceType: string | undefined = source.type; + if (sourceType !== "market" && sourceType !== "idle") { + throw new InvalidReallocationSourceTypeError(sourceType); + } + let sourceMarketId: MarketId | undefined; + if (source.type === "market") { + if (typeof source.adapter !== "string" || !isAddress(source.adapter)) { + throw new InvalidReallocationAddressError("from.adapter"); + } + if ( + source.marketParams == null || + !isAddress(source.marketParams.loanToken) || + !isAddress(source.marketParams.collateralToken) || + !isAddress(source.marketParams.oracle) || + !isAddress(source.marketParams.irm) || + typeof source.marketParams.lltv !== "bigint" + ) { + throw new InvalidReallocationSourceTypeError("market", "marketParams"); + } + sourceMarketId = MarketUtils.getMarketId(source.marketParams); + } + if (reallocation.penalty < 0n) { + throw new NegativeInputError( + "reallocation.penalty", + reallocation.penalty, + ); + } + if (reallocation.penalty > MAX_REALLOCATION_PENALTY) { + throw new InputExceedsMaxError({ + field: "reallocation.penalty", + value: reallocation.penalty, + max: MAX_REALLOCATION_PENALTY, + }); + } + if (reallocation.assets <= 0n) { + throw new NonPositiveInputError( + "reallocation.assets", + reallocation.assets, + ); + } + if (reallocation.assets > maxUint128) { + throw new InputExceedsMaxError({ + field: "reallocation.assets", + value: reallocation.assets, + max: maxUint128, + }); + } + + const penaltyKey = reallocation.vault.toLowerCase(); + const expectedPenalty = penaltyByVault.get(penaltyKey); + if ( + expectedPenalty !== undefined && + expectedPenalty !== reallocation.penalty + ) { + throw new InconsistentReallocationPenaltyError({ + vault: reallocation.vault, + expected: expectedPenalty, + actual: reallocation.penalty, + }); + } + penaltyByVault.set(penaltyKey, reallocation.penalty); + + if ( + sourceMarketId !== undefined && + compareMarketIds(sourceMarketId, targetMarketId) === 0 + ) { + throw new ReallocationWithdrawalOnTargetMarketError( + reallocation.vault, + sourceMarketId, + ); + } + } +}; + +/** + * Validates and normalizes a homogeneous Blue reallocation plan. + * + * @param params - Validation parameters. + * @param params.reallocations - Optional Vault V1 or Vault V2 reallocation plan. + * @param params.targetMarketId - Morpho Blue market receiving the liquidity. + * @param params.chainId - Chain whose allocator deployment is required for a V2 plan. + * @returns The validated plan tagged with its allocator version. + * @throws {BundlerErrors.UnexpectedAction} when a V2 plan is unsupported on the chain. + * @internal + */ +export const validateAndNormalizeReallocations = ({ + reallocations, + targetMarketId, + chainId, +}: { + readonly reallocations: BlueReallocationPlan | undefined; + readonly targetMarketId: MarketId; + readonly chainId: number; +}) => { + const vaultV1Reallocations: VaultV1Reallocation[] = []; + const vaultV2Reallocations: VaultV2BlueReallocation[] = []; + + for (const reallocation of reallocations ?? []) { + if (typeof reallocation !== "object" || reallocation === null) { + throw new InvalidReallocationShapeError(); + } + if ("from" in reallocation === "withdrawals" in reallocation) { + throw new InvalidReallocationShapeError(); + } + if ("withdrawals" in reallocation) { + vaultV1Reallocations.push(reallocation); + } else { + vaultV2Reallocations.push(reallocation); + } + } + + if (vaultV1Reallocations.length > 0 && vaultV2Reallocations.length > 0) { + throw new MixedReallocationVersionsError(); + } + if (vaultV2Reallocations.length > 0) { + validateVaultV2BlueReallocations(vaultV2Reallocations, targetMarketId); + if (getChainAddresses(chainId).vaultV2BluePublicAllocator == null) { + throw new BundlerErrors.UnexpectedAction( + vaultV2Reallocations[0]?.from.type === "market" + ? "vaultV2BluePublicAllocatorReallocate" + : "vaultV2BluePublicAllocatorAllocateFromIdle", + chainId, + ); + } + return { + type: "vaultV2Blue" as const, + reallocations: vaultV2Reallocations, + }; + } + + validateReallocations(vaultV1Reallocations, targetMarketId); + return { type: "vaultV1" as const, reallocations: vaultV1Reallocations }; +}; + /** * Validates that a slippage tolerance is within an acceptable range. * diff --git a/packages/morpho-sdk/src/types/AGENTS.md b/packages/morpho-sdk/src/types/AGENTS.md index b8c41ec5b..3fdb5785a 100644 --- a/packages/morpho-sdk/src/types/AGENTS.md +++ b/packages/morpho-sdk/src/types/AGENTS.md @@ -18,18 +18,18 @@ Centralized type definitions and error classes. Barrel-exported via `index.ts`. ## Shared liquidity (`sharedLiquidity.ts`) -- `ReallocationWithdrawal` — source market + amount. -- `VaultReallocation` — vault address + fee + withdrawals. - -Both map directly to `PublicAllocator.reallocateTo()` arguments. +- `VaultV1Reallocation` — vault address + fee + sorted withdrawals; maps to `reallocateTo()`. `VaultReallocation` is its deprecated compatibility alias. +- `VaultV2BlueReallocation` — BluePublicAllocator vault/source/target-adapter/assets/WAD-scaled-penalty input; maps 1:1 to `reallocate()` or `allocateFromIdle()` while deriving target market params from the enclosing Blue action. +- `VaultV2BluePublicAllocatorOptions` — canonical Vault V2 discovery and planner options for timestamp, enablement, vault allowlisting, friendly source-market utilization, and the maximum proportional penalty. +- `BlueReallocationPlan` — homogeneous iterable accepted by Blue action and entity pass-through surfaces; a plan contains only V1 or only V2 reallocations. ## Errors (`error.ts`) One class per error case. Never throw a generic `Error` from SDK source. -- **Generic input bounds:** `NegativeInputError` for values that must be non-negative and `NonPositiveInputError` for values that must be positive. Both expose the invalid `field` and `value`; reuse them across Vault, Blue, and Midnight instead of adding operation-specific scalar-bound errors. +- **Generic input bounds:** `NegativeInputError` for values that must be non-negative, `NonPositiveInputError` for values that must be positive, and `InputExceedsMaxError` for protocol upper bounds such as BluePublicAllocator's `uint128` assets and WAD-scaled `uint64` penalty. All expose the invalid `field` and `value`; reuse them across Vault, Blue, and Midnight instead of adding operation-specific scalar-bound errors. - **Market-specific:** `BorrowExceedsSafeLtvError`, `MissingMarketPriceError`, `NativeAmountOnNonWNativeAssetError`, `MutuallyExclusiveWithdrawAmountsError`, `WithdrawExceedsSupplyError`, `WithdrawSharesExceedSupplyError`. -- **Reallocation-specific:** `EmptyReallocationWithdrawalsError`, `ReallocationWithdrawalOnTargetMarketError`, `UnsortedReallocationWithdrawalsError`, `ReallocationWithdrawExceedsMarketSupplyError`. +- **Reallocation-specific:** `EmptyReallocationWithdrawalsError`, `InvalidReallocationShapeError` when an entry matches both or neither V1/V2 shape, `MixedReallocationVersionsError` when one plan contains both versions, `InvalidReallocationAddressError` for malformed BluePublicAllocator vault or adapter addresses, `InvalidReallocationSourceTypeError` for an absent, incomplete, or unknown BluePublicAllocator source, `InconsistentReallocationPenaltyError` for conflicting penalties on one vault, `ReallocationWithdrawalOnTargetMarketError`, `UnsortedReallocationWithdrawalsError`, `ReallocationWithdrawExceedsMarketSupplyError`. ## Adding a new operation diff --git a/packages/morpho-sdk/src/types/action.ts b/packages/morpho-sdk/src/types/action.ts index 8894a7afa..ae120746f 100644 --- a/packages/morpho-sdk/src/types/action.ts +++ b/packages/morpho-sdk/src/types/action.ts @@ -168,7 +168,10 @@ export interface BlueWithdrawAction shares: bigint; receiver: Address; minSharePrice: bigint; + /** Native-token fees paid to PublicAllocator V1. */ reallocationFee: bigint; + /** Loan-token assets donated as BluePublicAllocator V2 penalties. */ + readonly reallocationPenaltyAssets: bigint; } > {} @@ -191,7 +194,10 @@ export interface BlueBorrowAction amount: bigint; receiver: Address; minSharePrice: bigint; + /** Native-token fees paid to PublicAllocator V1. */ reallocationFee: bigint; + /** Loan-token assets donated as BluePublicAllocator V2 penalties. */ + readonly reallocationPenaltyAssets: bigint; } > {} @@ -206,7 +212,10 @@ export interface BlueSupplyCollateralBorrowAction onBehalf: Address; receiver: Address; nativeAmount?: bigint; + /** Native-token fees paid to PublicAllocator V1. */ reallocationFee: bigint; + /** Loan-token assets donated as BluePublicAllocator V2 penalties. */ + readonly reallocationPenaltyAssets: bigint; } > {} @@ -266,7 +275,10 @@ export interface BlueRefinanceAction readonly minBorrowSharePrice: bigint; readonly maxRepaySharePrice: bigint; readonly user: Address; + /** Native-token fees paid to PublicAllocator V1. */ readonly reallocationFee: bigint; + /** Loan-token assets donated as BluePublicAllocator V2 penalties. */ + readonly reallocationPenaltyAssets: bigint; } > {} diff --git a/packages/morpho-sdk/src/types/error.ts b/packages/morpho-sdk/src/types/error.ts index e5aa55870..ad52311d1 100644 --- a/packages/morpho-sdk/src/types/error.ts +++ b/packages/morpho-sdk/src/types/error.ts @@ -1,5 +1,5 @@ import { type MarketId, UnknownDataError } from "@morpho-org/blue-sdk"; -import type { Address } from "viem"; +import type { Address, Hash } from "viem"; /** * Thrown when a morpho-sdk input that must be non-negative is negative. @@ -55,6 +55,36 @@ export class NonPositiveInputError extends Error { } } +/** Thrown when an integer input exceeds its protocol-defined maximum. */ +export class InputExceedsMaxError extends Error { + /** + * @param params - Maximum-bound validation details. + * @param params.field - Public input field whose value is invalid. + * @param params.value - Supplied value. + * @param params.max - Largest accepted value. + */ + public constructor(params: { + readonly field: string; + readonly value: bigint; + readonly max: bigint; + }) { + super( + `Input "${params.field}" must be at most "${params.max}", got "${params.value}".`, + ); + this.field = params.field; + this.value = params.value; + this.max = params.max; + this.name = "InputExceedsMaxError"; + } + + /** Public input field whose value is invalid. */ + public readonly field: string; + /** Supplied value. */ + public readonly value: bigint; + /** Largest accepted value. */ + public readonly max: bigint; +} + /** Thrown when an in-kind redemption does not include any Morpho Blue market parameters. */ export class EmptyMarketParamsListError extends Error { public constructor() { @@ -464,6 +494,31 @@ export namespace BundlerErrors { super(`unexpected signature authorizing "${authorized}"`); } } + + /** + * Thrown when a skippable Blue Public Allocator call would leave a usable + * token allowance behind after the allocator call reverts. + * + * @example + * ```ts + * import { BundlerErrors } from "@morpho-org/morpho-sdk"; + * + * if (error instanceof BundlerErrors.SkippableAllocatorPenalty) { + * // Rebuild the allocator call with skipRevert set to false. + * } + * ``` + */ + export class SkippableAllocatorPenalty extends Error { + /** + * @param penaltyAssets - Exact token amount approved to the allocator. + */ + public constructor(public readonly penaltyAssets: bigint) { + super( + `Blue Public Allocator calls with penalty assets cannot skip reverts. Rebuild with skipRevert false for penalty amount "${penaltyAssets}".`, + ); + this.name = "SkippableAllocatorPenalty"; + } + } } /** Requirement signature kind accepted by action-output transaction builders. */ @@ -759,7 +814,7 @@ export class EmptyReallocationWithdrawalsError extends Error { } } -/** Thrown when a reallocation withdrawal references the operation's target market (which would be a no-op or self-deal). */ +/** Thrown when a Public Allocator source references the target Blue market. */ export class ReallocationWithdrawalOnTargetMarketError extends Error { constructor(vault: string, marketId: string) { super( @@ -768,6 +823,149 @@ export class ReallocationWithdrawalOnTargetMarketError extends Error { } } +/** + * Thrown when a Public Allocator reallocation does not match exactly one V1 or + * V2 input shape. + * + * @example + * ```ts + * import { InvalidReallocationShapeError } from "@morpho-org/morpho-sdk"; + * + * const error = new InvalidReallocationShapeError(); + * ``` + */ +export class InvalidReallocationShapeError extends Error { + public constructor() { + super( + 'Reallocation must contain either V1 "withdrawals" or V2 "from", but not both.', + ); + this.name = "InvalidReallocationShapeError"; + } +} + +/** + * Thrown when one reallocation plan contains both Vault V1 and Vault V2 entries. + * + * @example + * ```ts + * import { MixedReallocationVersionsError } from "@morpho-org/morpho-sdk"; + * + * const error = new MixedReallocationVersionsError(); + * ``` + */ +export class MixedReallocationVersionsError extends Error { + public constructor() { + super( + "Reallocation plans cannot mix Vault V1 and Vault V2 entries. Submit one version per transaction.", + ); + this.name = "MixedReallocationVersionsError"; + } +} + +/** + * Thrown when a Blue Public Allocator reallocation contains a malformed vault + * or adapter address. + * + * @example + * ```ts + * import { InvalidReallocationAddressError } from "@morpho-org/morpho-sdk"; + * + * const error = new InvalidReallocationAddressError("to.adapter"); + * if (error instanceof InvalidReallocationAddressError) { + * console.error(error.field); + * } + * ``` + */ +export class InvalidReallocationAddressError extends Error { + /** + * @param field - Reallocation address field that is absent or malformed. + */ + public constructor( + public readonly field: "vault" | "from.adapter" | "to.adapter", + ) { + super(`Reallocation "${field}" must be a valid address.`); + this.name = "InvalidReallocationAddressError"; + } +} + +/** + * Thrown when a Blue Public Allocator source is absent, incomplete, or has an + * unknown discriminator. + * + * @example + * ```ts + * import { InvalidReallocationSourceTypeError } from "@morpho-org/morpho-sdk"; + * + * const error = new InvalidReallocationSourceTypeError("marketTypo"); + * ``` + */ +export class InvalidReallocationSourceTypeError extends Error { + /** + * @param sourceType - Invalid runtime value received for `reallocation.from.type`, + * or `undefined` when the source or discriminator is absent. + * @param missingField - Required market-source field that is absent or malformed. + */ + public constructor( + public readonly sourceType: string | undefined, + public readonly missingField?: "adapter" | "marketParams", + ) { + super( + missingField == null + ? sourceType === undefined + ? 'Reallocation source must specify type "market" or "idle".' + : `Reallocation source type must be "market" or "idle", got "${sourceType}".` + : `Reallocation market source must include a valid "${missingField}".`, + ); + this.name = "InvalidReallocationSourceTypeError"; + } +} + +/** + * Thrown when one bundle assigns different penalty rates to the same Vault V2. + * + * @example + * ```ts + * import { InconsistentReallocationPenaltyError } from "@morpho-org/morpho-sdk"; + * import type { Address } from "viem"; + * + * const vaultFixture = + * "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" satisfies Address; + * const error = new InconsistentReallocationPenaltyError({ + * vault: vaultFixture, + * expected: 5n, + * actual: 11n, + * }); + * ``` + */ +export class InconsistentReallocationPenaltyError extends Error { + /** Vault whose configured penalty must be reused. */ + public readonly vault: Address; + /** Penalty rate established by the first matching bundle entry. */ + public readonly expected: bigint; + /** Conflicting penalty rate supplied by a later bundle entry. */ + public readonly actual: bigint; + + /** + * @param params - Conflicting vault penalty details. + * @param params.vault - Vault whose configured penalty applies. + * @param params.expected - Penalty rate established by the first matching entry. + * @param params.actual - Conflicting penalty rate supplied by a later entry. + */ + public constructor(params: { + readonly vault: Address; + readonly expected: bigint; + readonly actual: bigint; + }) { + super( + `Penalty for vault "${params.vault}" must remain "${params.expected}" across the bundle, got "${params.actual}". Use the vault's configured penalty for every call.`, + ); + this.vault = params.vault; + this.expected = params.expected; + this.actual = params.actual; + this.name = "InconsistentReallocationPenaltyError"; + } +} + /** Thrown when reallocation withdrawals within a vault are not strictly sorted by market id. */ export class UnsortedReallocationWithdrawalsError extends Error { constructor(vault: string, marketId: string) { @@ -889,8 +1087,8 @@ export class DisabledReallocationMarketError extends Error { } /** - * Thrown when shared liquidity selected by `computeReallocations` cannot cover - * the operation's absolute shortfall on the target market — the resulting + * Thrown when shared liquidity selected by a Vault V1 or Vault V2 reallocation planner cannot + * cover the operation's absolute shortfall on the target market — the resulting * `morphoBorrow` or `morphoWithdraw` would still revert onchain. * * Pattern-match on the class and inspect `params` to surface the gap to users. @@ -959,6 +1157,99 @@ export class UnknownReallocationPositionError extends UnknownDataError { } } +/** Thrown when Vault V2 reallocation state does not contain a requested allocation id. */ +export class UnknownReallocationAllocationError extends UnknownDataError { + /** + * @param vault - Vault V2 address for the missing allocation. + * @param id - Missing Vault V2 allocation id. + */ + constructor( + public readonly vault: Address, + public readonly id: Hash, + ) { + super(`unknown reallocation allocation "${id}" for vault "${vault}"`); + } +} + +/** Thrown when Vault V2 reallocation state lacks the vault-wide allocator configuration. */ +export class UnknownReallocationPublicAllocatorConfigError extends UnknownDataError { + /** @param vault - Vault V2 address with missing allocator configuration. */ + constructor(public readonly vault: Address) { + super(`unknown public allocator configuration for vault "${vault}"`); + } +} + +/** Thrown when Vault V2 reallocation state lacks the fetched active-adapter set. */ +export class UnknownReallocationActiveAdaptersError extends UnknownDataError { + /** @param vault - Vault V2 address with missing active-adapter state. */ + constructor(public readonly vault: Address) { + super(`unknown active adapters for reallocation vault "${vault}"`); + } +} + +/** Thrown when Vault V2 reallocation state lacks an adapter-market allocator configuration. */ +export class UnknownReallocationMarketPublicAllocatorConfigError extends UnknownDataError { + /** + * @param vault - Vault V2 address for the missing configuration. + * @param adapterMarketCapId - Missing adapter-scoped market cap id. + */ + constructor( + public readonly vault: Address, + public readonly adapterMarketCapId: Hash, + ) { + super( + `unknown public allocator configuration "${adapterMarketCapId}" for vault "${vault}"`, + ); + } +} + +/** Thrown when Vault V2 reallocation state does not contain a requested adapter. */ +export class UnknownReallocationAdapterError extends UnknownDataError { + /** + * @param vault - Vault V2 address expected to own the adapter. + * @param adapter - Missing adapter address. + */ + constructor( + public readonly vault: Address, + public readonly adapter: Address, + ) { + super(`unknown reallocation adapter "${adapter}" for vault "${vault}"`); + } +} + +/** Thrown when a simulated Vault V2 allocation transition would underflow. */ +export class ReallocationAllocationUnderflowError extends Error { + constructor( + public readonly params: { + readonly vault: Address; + readonly id: Hash; + readonly allocation: bigint; + readonly change: bigint; + }, + ) { + super( + `Reallocation change "${params.change}" exceeds allocation "${params.allocation}" for id "${params.id}" on vault "${params.vault}". Refresh the reallocation data and recompute the plan.`, + ); + } +} + +/** Thrown when a simulated Vault V2 market withdrawal exceeds the adapter's supply shares. */ +export class ReallocationAdapterSupplySharesUnderflowError extends Error { + constructor( + public readonly params: { + readonly vault: Address; + readonly adapter: Address; + readonly marketId: MarketId; + readonly supplyShares: bigint; + readonly withdrawnShares: bigint; + }, + ) { + super( + `Reallocation withdraw shares "${params.withdrawnShares}" exceed adapter supply shares "${params.supplyShares}" on market "${params.marketId}" for adapter "${params.adapter}". Refresh the reallocation data and recompute the plan.`, + ); + } +} + /** Thrown when a Midnight amount exceeds the maximum offer-cap value accepted onchain. */ export class MidnightAmountExceedsMaxOfferCapError extends Error { constructor(params: { @@ -1199,7 +1490,7 @@ export class WithdrawSharesExceedSupplyError extends Error { } /** - * Thrown when `computeReallocations` is called with a withdraw `amount` greater + * Thrown when a Vault V1 or Vault V2 reallocation planner receives a withdraw `amount` greater * than the target market's current `totalSupplyAssets` — the post-withdraw * supply would be negative, making the on-chain `morphoWithdraw` revert * regardless of any reallocation. Caught here so callers do not pay diff --git a/packages/morpho-sdk/src/types/sharedLiquidity.ts b/packages/morpho-sdk/src/types/sharedLiquidity.ts index 77d6499fe..9b391a7e3 100644 --- a/packages/morpho-sdk/src/types/sharedLiquidity.ts +++ b/packages/morpho-sdk/src/types/sharedLiquidity.ts @@ -16,17 +16,17 @@ export interface PublicAllocatorOptions { /** * Vaults to consider for reallocation. They must have enabled the PublicAllocator. + * Arrays, readonly arrays, sets, and other iterables are accepted. * Defaults to all vaults present in the reallocation data. */ - readonly reallocatableVaults?: readonly Address[]; + readonly reallocatableVaults?: Iterable
; /** * The maximum utilization each source market may reach when withdrawing * shared liquidity, scaled by WAD. * - * @deprecated The source-market withdrawal ceiling is fixed at 90% - * ({@link DEFAULT_WITHDRAWAL_TARGET_UTILIZATION}) and will stop being - * configurable in the next major. Per-market overrides are still honored for now. + * @deprecated Per-market source ceilings will be removed in the next major. + * Use `defaultMaxWithdrawalUtilization` to configure one ceiling for every source. */ readonly maxWithdrawalUtilization?: Readonly< Record @@ -37,13 +37,43 @@ export interface PublicAllocatorOptions { * shared liquidity, scaled by WAD. * * @default 90% (900000000000000000n) - * @deprecated The source-market withdrawal ceiling is fixed at 90% - * ({@link DEFAULT_WITHDRAWAL_TARGET_UTILIZATION}) and will stop being - * configurable in the next major. Overrides are still honored for now. */ readonly defaultMaxWithdrawalUtilization?: bigint; } +/** Options controlling Vault V2 BluePublicAllocator reallocation discovery. */ +export interface VaultV2BluePublicAllocatorOptions { + /** Whether Vault V2 public allocator discovery is enabled. */ + readonly enabled?: boolean; + + /** Timestamp at which market and Vault V2 interest is evaluated. */ + readonly timestamp?: BigIntish; + + /** + * Vault V2 addresses to consider. Arrays, readonly arrays, sets, and other + * iterables are accepted. Defaults to every vault in the reallocation data. + */ + readonly reallocatableVaults?: Iterable
; + + /** + * Maximum utilization source markets may reach during friendly discovery, + * scaled by WAD. The amount-aware planner falls back to 100% only when the + * friendly phase cannot cover the operation's absolute shortfall. + * + * @default 90% (900000000000000000n) + */ + readonly maxWithdrawalUtilization?: bigint; + + /** + * Maximum proportional vault-asset penalty accepted for each + * BluePublicAllocator call, scaled by WAD. Vaults with a higher configured + * penalty are ignored. Must not exceed WAD (100%). + * + * @default 0n + */ + readonly maxPenalty?: bigint; +} + /** * A computed source-market withdrawal before it is grouped by vault. */ @@ -73,7 +103,7 @@ export interface ReallocationWithdrawal { * Maps 1:1 to a `PublicAllocator.reallocateTo()` call. * Withdraws from source markets and supplies to the target market. */ -export interface VaultReallocation { +export interface VaultV1Reallocation { readonly vault: Address; /** Fee in native token (ETH) paid to the PublicAllocator for this vault. */ readonly fee: bigint; @@ -81,6 +111,51 @@ export interface VaultReallocation { readonly withdrawals: readonly ReallocationWithdrawal[]; } +/** Source of a Vault V2 BluePublicAllocator reallocation. */ +export type VaultV2BlueReallocationSource = + | { + /** Reallocate from a Morpho Blue market. */ + readonly type: "market"; + /** Vault V2 adapter supplying the source market. */ + readonly adapter: Address; + /** Source market parameters. */ + readonly marketParams: MarketParams; + } + | { + /** Allocate from vault idle liquidity without a synthetic market. */ + readonly type: "idle"; + }; + +/** + * One Blue Public Allocator contract call performed before a Blue action. + * + * The target market parameters are derived from the enclosing Blue action. + */ +export interface VaultV2BlueReallocation { + /** Vault whose liquidity is moved. */ + readonly vault: Address; + /** Liquidity source. */ + readonly from: VaultV2BlueReallocationSource; + /** Target Vault V2 adapter; the target market comes from the enclosing action. */ + readonly to: { readonly adapter: Address }; + /** Asset amount, which must fit in `uint128`. */ + readonly assets: bigint; + /** Vault-configured WAD-scaled penalty rate passed to the allocator. */ + readonly penalty: bigint; +} + +/** A homogeneous Blue action plan containing only Vault V1 or only Vault V2 reallocations. */ +export type BlueReallocationPlan = + | Iterable + | Iterable; + +/** + * Deprecated name for a Vault V1 reallocation. + * + * @deprecated Use {@link VaultV1Reallocation} instead. + */ +export type VaultReallocation = VaultV1Reallocation; + /** * Options for computing vault reallocations via the public allocator. * diff --git a/packages/morpho-sdk/src/utils.ts b/packages/morpho-sdk/src/utils.ts index 0d8f607da..95e82eb9c 100644 --- a/packages/morpho-sdk/src/utils.ts +++ b/packages/morpho-sdk/src/utils.ts @@ -6,6 +6,7 @@ export { MathLib, SharesMath, VaultUtils, + VaultV2Utils, } from "@morpho-org/blue-sdk"; export { decodeBytes32String, @@ -63,7 +64,10 @@ export { transformValue, values, } from "@morpho-org/morpho-ts"; -export { computeReallocations } from "./helpers/computeReallocations.js"; +export { + computeReallocations, + computeVaultV1Reallocations, +} from "./helpers/computeVaultV1Reallocations.js"; export { addTransactionMetadata } from "./helpers/metadata.js"; export { computeMaxRepaySharePrice, diff --git a/packages/morpho-sdk/test/actions/blue/reallocations.test.ts b/packages/morpho-sdk/test/actions/blue/reallocations.test.ts index 26de4caf0..888bbd247 100644 --- a/packages/morpho-sdk/test/actions/blue/reallocations.test.ts +++ b/packages/morpho-sdk/test/actions/blue/reallocations.test.ts @@ -1,5 +1,5 @@ import { getChainAddresses } from "@morpho-org/blue-sdk"; -import { publicAllocatorAbi } from "@morpho-org/blue-sdk-viem"; +import { vaultV1PublicAllocatorAbi } from "@morpho-org/blue-sdk-viem"; import { type Address, encodeFunctionData, parseUnits } from "viem"; import { base, mainnet } from "viem/chains"; @@ -209,7 +209,7 @@ describe("Borrow with reallocation fee", () => { }); // Impersonate the PA admin to set a fee on the Steakhouse vault - const { publicAllocator } = getChainAddresses(mainnet.id); + const { vaultV1PublicAllocator } = getChainAddresses(mainnet.id); await client.impersonateAccount({ address: PA_ADMIN }); await client.setBalance({ address: PA_ADMIN, @@ -217,9 +217,9 @@ describe("Borrow with reallocation fee", () => { }); await client.sendTransaction({ account: PA_ADMIN, - to: publicAllocator, + to: vaultV1PublicAllocator, data: encodeFunctionData({ - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "setFee", args: [SteakhouseUsdcVaultV1.address, reallocationFee], }), @@ -241,7 +241,7 @@ describe("Borrow with reallocation fee", () => { ]; const publicAllocatorBalanceBefore = await client.getBalance({ - address: publicAllocator!, + address: vaultV1PublicAllocator!, }); const { @@ -299,7 +299,7 @@ describe("Borrow with reallocation fee", () => { ).toEqual(reallocationAmount + marketAccruedInterest); const publicAllocatorBalanceAfter = await client.getBalance({ - address: publicAllocator!, + address: vaultV1PublicAllocator!, }); expect(publicAllocatorBalanceAfter).toEqual( publicAllocatorBalanceBefore + reallocationFee, @@ -505,7 +505,7 @@ describe("SupplyCollateralBorrow with reallocation fee", () => { }); // Impersonate the PA admin to set a fee on the Steakhouse vault - const { publicAllocator } = getChainAddresses(mainnet.id); + const { vaultV1PublicAllocator } = getChainAddresses(mainnet.id); await client.impersonateAccount({ address: PA_ADMIN }); await client.setBalance({ address: PA_ADMIN, @@ -513,9 +513,9 @@ describe("SupplyCollateralBorrow with reallocation fee", () => { }); await client.sendTransaction({ account: PA_ADMIN, - to: publicAllocator, + to: vaultV1PublicAllocator, data: encodeFunctionData({ - abi: publicAllocatorAbi, + abi: vaultV1PublicAllocatorAbi, functionName: "setFee", args: [SteakhouseUsdcVaultV1.address, reallocationFee], }), @@ -537,7 +537,7 @@ describe("SupplyCollateralBorrow with reallocation fee", () => { ]; const publicAllocatorBalanceBefore = await client.getBalance({ - address: publicAllocator!, + address: vaultV1PublicAllocator!, }); const { @@ -608,7 +608,7 @@ describe("SupplyCollateralBorrow with reallocation fee", () => { ).toEqual(reallocationAmount + marketAccruedInterest); const publicAllocatorBalanceAfter = await client.getBalance({ - address: publicAllocator!, + address: vaultV1PublicAllocator!, }); expect(publicAllocatorBalanceAfter).toEqual( publicAllocatorBalanceBefore + reallocationFee, @@ -616,22 +616,22 @@ describe("SupplyCollateralBorrow with reallocation fee", () => { }); }); -describe("getReallocationData and getReallocations", () => { - test("should reject getReallocationData when the client chain differs from the market chain", async ({ +describe("getVaultV1ReallocationData and getVaultV1Reallocations", () => { + test("should reject getVaultV1ReallocationData when the client chain differs from the market chain", async ({ client, }) => { const morphoClient = client.extend(morphoViemExtension()).morpho; const market = morphoClient.blue(CbbtcUsdcBlue, base.id); await expect( - market.getReallocationData({ + market.getVaultV1ReallocationData({ vaultAddresses: [SteakhouseUsdcVaultV1.address], block: { number: 0n, timestamp: 0n }, }), ).rejects.toBeInstanceOf(ChainIdMismatchError); }); - test("should compute reallocations and borrow using getReallocationData + getReallocations", async ({ + test("should compute reallocations and borrow using getVaultV1ReallocationData + getVaultV1Reallocations", async ({ client, }) => { const collateralAmount = parseUnits("1000", 8); @@ -662,14 +662,15 @@ describe("getReallocationData and getReallocations", () => { const block = await client.getBlock(); - const reallocationData = await market.getReallocationData({ + const reallocationData = await market.getVaultV1ReallocationData({ vaultAddresses: [SteakhouseUsdcVaultV1.address], block, }); - const reallocations = market.getReallocations({ + const reallocations = market.getVaultV1Reallocations({ reallocationData, - borrowAmount, + operation: "borrow", + amount: borrowAmount, options: { timestamp: block.timestamp }, }); @@ -741,14 +742,15 @@ describe("getReallocationData and getReallocations", () => { const block = await client.getBlock(); - const reallocationData = await market.getReallocationData({ + const reallocationData = await market.getVaultV1ReallocationData({ vaultAddresses: [SteakhouseUsdcVaultV1.address], block, }); - const reallocations = market.getReallocations({ + const reallocations = market.getVaultV1Reallocations({ reallocationData, - borrowAmount, + operation: "borrow", + amount: borrowAmount, options: { timestamp: block.timestamp }, }); @@ -801,14 +803,15 @@ describe("getReallocationData and getReallocations", () => { const block = await client.getBlock(); - const reallocationData = await market.getReallocationData({ + const reallocationData = await market.getVaultV1ReallocationData({ vaultAddresses: [SteakhouseUsdcVaultV1.address], block, }); - const reallocations = market.getReallocations({ + const reallocations = market.getVaultV1Reallocations({ reallocationData, - borrowAmount, + operation: "borrow", + amount: borrowAmount, options: { timestamp: block.timestamp }, }); diff --git a/packages/morpho-sdk/test/helpers/vaultV2.ts b/packages/morpho-sdk/test/helpers/vaultV2.ts index e4f8ba14c..60551f090 100644 --- a/packages/morpho-sdk/test/helpers/vaultV2.ts +++ b/packages/morpho-sdk/test/helpers/vaultV2.ts @@ -1,7 +1,18 @@ import { getChainAddresses } from "@morpho-org/blue-sdk"; -import { vaultV2FactoryAbi } from "@morpho-org/blue-sdk-viem"; +import { + morphoMarketV1AdapterV2FactoryAbi, + vaultV2Abi, + vaultV2FactoryAbi, +} from "@morpho-org/blue-sdk-viem"; import type { AnvilTestClient } from "@morpho-org/test"; -import { type Address, decodeEventLog, parseEventLogs, toHex } from "viem"; +import { + type Address, + decodeEventLog, + type Hex, + parseEther, + parseEventLogs, + toHex, +} from "viem"; export async function createVaultV2(params: { client: AnvilTestClient; @@ -46,3 +57,74 @@ export async function createVaultV2(params: { return { address: vaultAddress }; } + +export const submitAndAcceptVaultV2Call = async ( + client: AnvilTestClient, + params: { readonly vault: Address; readonly data: Hex }, +) => { + const { vault, data } = params; + await client.writeContract({ + address: vault, + abi: vaultV2Abi, + functionName: "submit", + args: [data], + }); + const hash = await client.sendTransaction({ to: vault, data }); + await client.waitForTransactionReceipt({ hash }); +}; + +export const deployVaultV2 = async ( + client: AnvilTestClient, + asset: Address, +) => { + await client.deal({ amount: parseEther("1") }); + const { address: vault } = await createVaultV2({ + client, + asset, + chainId: client.chain.id, + }); + await client.writeContract({ + address: vault, + abi: vaultV2Abi, + functionName: "setCurator", + args: [client.account.address], + }); + + return vault; +}; + +export const deployMorphoMarketV1AdapterV2 = async ( + client: AnvilTestClient, + vault: Address, +) => { + const { morphoMarketV1AdapterV2Factory } = getChainAddresses(client.chain.id); + const hash = await client.writeContract({ + address: morphoMarketV1AdapterV2Factory!, + abi: morphoMarketV1AdapterV2FactoryAbi, + functionName: "createMorphoMarketV1AdapterV2", + args: [vault], + }); + const receipt = await client.waitForTransactionReceipt({ hash }); + const event = receipt.logs + .map((log) => { + try { + return decodeEventLog({ + abi: morphoMarketV1AdapterV2FactoryAbi, + data: log.data, + topics: log.topics, + }); + } catch { + return undefined; + } + }) + .find( + (candidate) => + candidate?.eventName === "CreateMorphoMarketV1AdapterV2" && + "morphoMarketV1AdapterV2" in candidate.args, + ); + if (event?.eventName !== "CreateMorphoMarketV1AdapterV2") { + throw new Error("No CreateMorphoMarketV1AdapterV2 event found."); + } + + return event.args.morphoMarketV1AdapterV2; +}; diff --git a/packages/morpho-ts/AGENTS.md b/packages/morpho-ts/AGENTS.md index a861ff506..03cf5a8c0 100644 --- a/packages/morpho-ts/AGENTS.md +++ b/packages/morpho-ts/AGENTS.md @@ -1,6 +1,7 @@ # morpho-ts Conventions - Keep this package framework-free and dependency-light; export generic helpers plus cross-protocol SDK primitives that Blue and Midnight both need, including shared math, typed errors, constants, address/hex/call descriptor types, shared ABI literals, and address/deployment registries. +- `marketParamsAbi` is canonically defined in this package's `abis` subpath; protocol packages may re-export it for compatibility but must not redefine it. - Preserve nullability through helpers, e.g. `transformValue(value, fn)` returns nullish input unchanged. - Helpers should preserve input type shape unless their name explicitly signals formatting or conversion. - Use type guards for filtering, e.g. `array.filter(isDefined)`. diff --git a/packages/morpho-ts/src/abis.ts b/packages/morpho-ts/src/abis.ts index 470e5652e..3ae374f68 100644 --- a/packages/morpho-ts/src/abis.ts +++ b/packages/morpho-ts/src/abis.ts @@ -4038,8 +4038,20 @@ export const metaMorphoAbi = [ }, ] as const; -/** PublicAllocator ABI used to read vault allocator configuration and flow caps. */ -export const publicAllocatorAbi = [ +/** ABI tuple definition for Morpho Blue market params. */ +export const marketParamsAbi = { + type: "tuple", + components: [ + { type: "address", name: "loanToken" }, + { type: "address", name: "collateralToken" }, + { type: "address", name: "oracle" }, + { type: "address", name: "irm" }, + { type: "uint256", name: "lltv" }, + ], +} as const; + +/** Vault V1 PublicAllocator ABI used to read vault allocator configuration and flow caps. */ +export const vaultV1PublicAllocatorAbi = [ { inputs: [ { @@ -4442,33 +4454,7 @@ export const publicAllocatorAbi = [ { components: [ { - components: [ - { - internalType: "address", - name: "loanToken", - type: "address", - }, - { - internalType: "address", - name: "collateralToken", - type: "address", - }, - { - internalType: "address", - name: "oracle", - type: "address", - }, - { - internalType: "address", - name: "irm", - type: "address", - }, - { - internalType: "uint256", - name: "lltv", - type: "uint256", - }, - ], + components: marketParamsAbi.components, internalType: "struct MarketParams", name: "marketParams", type: "tuple", @@ -4484,33 +4470,7 @@ export const publicAllocatorAbi = [ type: "tuple[]", }, { - components: [ - { - internalType: "address", - name: "loanToken", - type: "address", - }, - { - internalType: "address", - name: "collateralToken", - type: "address", - }, - { - internalType: "address", - name: "oracle", - type: "address", - }, - { - internalType: "address", - name: "irm", - type: "address", - }, - { - internalType: "uint256", - name: "lltv", - type: "uint256", - }, - ], + components: marketParamsAbi.components, internalType: "struct MarketParams", name: "supplyMarketParams", type: "tuple", @@ -4619,6 +4579,309 @@ export const publicAllocatorAbi = [ }, ] as const; +/** + * Deprecated alias for the Vault V1 PublicAllocator ABI. + * + * @deprecated Use `vaultV1PublicAllocatorAbi` instead. + */ +export const publicAllocatorAbi = vaultV1PublicAllocatorAbi; + +/** Vault V2 Blue Public Allocator ABI used for configuration and reallocations. */ +export const vaultV2BluePublicAllocatorAbi = [ + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + ], + name: "absoluteCap", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "address", + name: "adapter", + type: "address", + }, + { + internalType: "bool", + name: "newIsActiveAdapter", + type: "bool", + }, + ], + name: "setIsActiveAdapter", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "address", + name: "adapter", + type: "address", + }, + { + components: marketParamsAbi.components, + internalType: "struct MarketParams", + name: "marketParams", + type: "tuple", + }, + { + internalType: "uint256", + name: "newAbsoluteCap", + type: "uint256", + }, + ], + name: "setAbsoluteCap", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "address", + name: "adapter", + type: "address", + }, + { + components: marketParamsAbi.components, + internalType: "struct MarketParams", + name: "marketParams", + type: "tuple", + }, + { + internalType: "bool", + name: "newCanPullFromMarket", + type: "bool", + }, + ], + name: "setCanPullFromMarket", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "bool", + name: "newCanPullFromIdle", + type: "bool", + }, + ], + name: "setCanPullFromIdle", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "uint64", + name: "newPenalty", + type: "uint64", + }, + ], + name: "setPenalty", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "bytes32", + name: "id", + type: "bytes32", + }, + ], + name: "canPullFromMarket", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "address", + name: "adapter", + type: "address", + }, + ], + name: "isActiveAdapter", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + ], + name: "vaultData", + outputs: [ + { + internalType: "bool", + name: "canPullFromIdle", + type: "bool", + }, + { + internalType: "uint64", + name: "penalty", + type: "uint64", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "address", + name: "deallocateAdapter", + type: "address", + }, + { + components: marketParamsAbi.components, + internalType: "struct MarketParams", + name: "deallocateMarketParams", + type: "tuple", + }, + { + internalType: "address", + name: "allocateAdapter", + type: "address", + }, + { + components: marketParamsAbi.components, + internalType: "struct MarketParams", + name: "allocateMarketParams", + type: "tuple", + }, + { + internalType: "uint128", + name: "assets", + type: "uint128", + }, + { + internalType: "uint64", + name: "penalty", + type: "uint64", + }, + ], + name: "reallocate", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "vault", + type: "address", + }, + { + internalType: "address", + name: "adapter", + type: "address", + }, + { + components: marketParamsAbi.components, + internalType: "struct MarketParams", + name: "marketParams", + type: "tuple", + }, + { + internalType: "uint128", + name: "assets", + type: "uint128", + }, + { + internalType: "uint64", + name: "penalty", + type: "uint64", + }, + ], + name: "allocateFromIdle", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + /** Wrapped Backed token ABI used to discover permissioning controllers. */ export const wrappedBackedTokenAbi = [ { diff --git a/packages/morpho-ts/src/addresses.test.ts b/packages/morpho-ts/src/addresses.test.ts index 0f0b5fa8f..bd2bf9d50 100644 --- a/packages/morpho-ts/src/addresses.test.ts +++ b/packages/morpho-ts/src/addresses.test.ts @@ -142,6 +142,96 @@ describe("addressesRegistry", () => { expect("midnight" in addressesRegistry[1]).toBe(false); }); + test("behavior: keeps the deprecated Vault V1 PublicAllocator alias", () => { + const { publicAllocator, vaultV1PublicAllocator } = + addressesRegistry[ChainId.EthMainnet]; + + expect(publicAllocator).toBe(vaultV1PublicAllocator); + expect(getChainAddress(ChainId.EthMainnet, "publicAllocator")).toBe( + vaultV1PublicAllocator, + ); + }); + + test.each([ + [ + ChainId.EthMainnet, + ["0x00b8e1509398ED692C3F326CbAf1694F9A881e27", 25770408n], + ], + [ + ChainId.BaseMainnet, + ["0xAED282B8aD9257BB1272e93aE63A32A53621e412", 50063965n], + ], + [ + ChainId.ArbitrumMainnet, + ["0x85b66Fe31e6788E5a6825EAe689f4c6c38AF3704", 495274087n], + ], + [ + ChainId.OptimismMainnet, + ["0xc6945A915Bb7e2A365469f120A33D2FA42951cF3", 155659263n], + ], + [ + ChainId.PolygonMainnet, + ["0xAb06a92cd253Bc12Dec8f719a693a6b472CCDfF4", 92141509n], + ], + [ + ChainId.WorldChainMainnet, + ["0x5Fe47f63ACd84f8A69b97E0a5122fCBff08Df48F", 33790828n], + ], + [ + ChainId.Unichain, + ["0x2b7Bf2f2027bcfE3A1F6Bc93EA80220a883a6851", 56168924n], + ], + [ + ChainId.HyperliquidMainnet, + ["0x056dd7D4B373ED26c788190085CC6C52B8e7479d", 43372279n], + ], + [ + ChainId.KatanaMainnet, + ["0xd952175e940D97775cBC5a523977a6f091D0d702", 40217302n], + ], + [ + ChainId.MonadMainnet, + ["0x0A503aB026EFACBC0F7feE7795F34B80b5B9a662", 96602489n], + ], + [ + ChainId.StableMainnet, + ["0x5C884d4B1510EAd302EC50A2AB4DE9c0b9E407ce", 35817019n], + ], + [ + ChainId.TempoMainnet, + ["0xDC9693CE6488640faEf173Ec2635ff99fdC25a07", 35177253n], + ], + [ + ChainId.KaiaMainnet, + ["0x3b369B37eba1655e8c44bC08E3A604D592c4a14F", 224866814n], + ], + [ + ChainId.MorphMainnet, + ["0x20d990D9eBf8003Df8cAD3Aa36aeF4404e3Ccb86", 25455933n], + ], + [ + ChainId.MegaEthMainnet, + ["0xB4A1B0EF18d169c19fC7617aCE898A06Dc495a7C", 24269516n], + ], + [ + ChainId.RobinhoodMainnet, + ["0xCe5c1aFa115fF8b1D6913509bfc79D9AE08CC857", 38318973n], + ], + ] as const)( + "behavior: exposes BluePublicAllocator on chain %i", + (chainId, [vaultV2BluePublicAllocator, deploymentBlock]) => { + expect(addressesRegistry[chainId].vaultV2BluePublicAllocator).toBe( + vaultV2BluePublicAllocator, + ); + expect(getChainAddress(chainId, "vaultV2BluePublicAllocator")).toBe( + vaultV2BluePublicAllocator, + ); + expect(deployments[chainId].vaultV2BluePublicAllocator).toBe( + deploymentBlock, + ); + }, + ); + test("behavior: exposes World Chain USDC permit v2 token", () => { const usdc = "0x79A02482A880bCE3F13e09Da970dC34db4CD24d1"; @@ -530,6 +620,46 @@ describe("registerCustomAddresses", () => { ); }); + test("behavior: normalizes Vault V1 PublicAllocator aliases", () => { + const chainId = 31_337_013; + const publicAllocator = randomAddress(); + + registerCustomAddresses({ + addresses: { + [chainId]: { ...createBlueAddresses(), publicAllocator }, + }, + deployments: { + [chainId]: { ...createBlueDeployments(), publicAllocator: 11n }, + }, + }); + + expect(addressesRegistry[chainId]?.vaultV1PublicAllocator).toBe( + publicAllocator, + ); + expect(addressesRegistry[chainId]?.publicAllocator).toBe(publicAllocator); + expect(deployments[chainId]?.vaultV1PublicAllocator).toBe(11n); + expect(deployments[chainId]?.publicAllocator).toBe(11n); + }); + + test("behavior: backfills deprecated PublicAllocator aliases", () => { + const chainId = 31_337_014; + const vaultV1PublicAllocator = randomAddress(); + + registerCustomAddresses({ + addresses: { + [chainId]: { ...createBlueAddresses(), vaultV1PublicAllocator }, + }, + deployments: { + [chainId]: { ...createBlueDeployments(), vaultV1PublicAllocator: 11n }, + }, + }); + + expect(addressesRegistry[chainId]?.publicAllocator).toBe( + vaultV1PublicAllocator, + ); + expect(deployments[chainId]?.publicAllocator).toBe(11n); + }); + test("error: RegistryValueAlreadyRegisteredError for addresses", () => { const chainId = 31_337_009; const chainAddresses = createChainAddresses(); @@ -555,6 +685,22 @@ describe("registerCustomAddresses", () => { expect(getChainAddress(chainId, "midnight")).toBe(chainAddresses.midnight); }); + test("error: conflicting PublicAllocator addresses", () => { + const chainId = 31_337_015; + + expect(() => + registerCustomAddresses({ + addresses: { + [chainId]: { + ...createBlueAddresses(), + vaultV1PublicAllocator: randomAddress(), + publicAllocator: randomAddress(), + }, + }, + }), + ).toThrow(RegistryValueAlreadyRegisteredError); + }); + test("error: IncompleteChainRegistryError for custom-chain addresses", () => { const chainId = 31_337_012; const partialAddresses = createMidnightAddresses() as ChainAddresses; @@ -654,6 +800,22 @@ describe("registerCustomAddresses", () => { expect(deployments[chainId]?.midnight).toBe(chainDeployments.midnight); }); + test("error: conflicting PublicAllocator deployments", () => { + const chainId = 31_337_108; + + expect(() => + registerCustomAddresses({ + deployments: { + [chainId]: { + ...createBlueDeployments(), + vaultV1PublicAllocator: 11n, + publicAllocator: 12n, + }, + }, + }), + ).toThrow(RegistryValueAlreadyRegisteredError); + }); + test("behavior: does not freeze caller-owned nested inputs", () => { const chainId = 31_337_106; const chainAddresses = createChainAddresses(); diff --git a/packages/morpho-ts/src/addresses.ts b/packages/morpho-ts/src/addresses.ts index 9ace9cc18..c9b327f73 100644 --- a/packages/morpho-ts/src/addresses.ts +++ b/packages/morpho-ts/src/addresses.ts @@ -62,8 +62,16 @@ export interface ChainAddresses { }; /** AdaptiveCurveIrm contract that lets Morpho update utilization-responsive borrow rates per market. */ adaptiveCurveIrm: `0x${string}`; - /** PublicAllocator contract for permissionless MetaMorpho reallocations subject to flow caps and vault fees. */ + /** Vault V1 PublicAllocator contract for permissionless MetaMorpho reallocations subject to flow caps and vault fees. */ + vaultV1PublicAllocator?: `0x${string}`; + /** + * Deprecated alias for the Vault V1 PublicAllocator contract. + * + * @deprecated Use `vaultV1PublicAllocator` instead. + */ publicAllocator?: `0x${string}`; + /** Vault V2 BluePublicAllocator contract for permissionless reallocations subject to allocation caps and penalties. */ + vaultV2BluePublicAllocator?: `0x${string}`; /** MetaMorpho factory that creates and indexes Morpho Vault V1 ERC4626 vaults. */ metaMorphoFactory?: `0x${string}`; /** VaultV2 factory that creates and indexes Morpho Vault V2 ERC4626/ERC2612 vaults. */ @@ -144,7 +152,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xaf85aF286637A033BE7d59ED8cC566afa3309B02", }, adaptiveCurveIrm: "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC", + vaultV1PublicAllocator: "0xfd32fA2ca22c76dD6E550706Ad913FC6CE91c75D", publicAllocator: "0xfd32fA2ca22c76dD6E550706Ad913FC6CE91c75D", + vaultV2BluePublicAllocator: "0x00b8e1509398ED692C3F326CbAf1694F9A881e27", metaMorphoFactory: "0x1897A8997241C1cD4bD0698647e4EB7213535c24", vaultV2Factory: "0xA1D94F746dEfa1928926b84fB2596c06926C0405", morphoMarketV1AdapterFactory: "0xb049465969ac6355127cDf9E88deE63d25204d5D", @@ -236,7 +246,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xE52E169C342C096C4949ABb944DC9f30E3F5Ea84", }, adaptiveCurveIrm: "0x46415998764C29aB2a25CbeA6254146D50D22687", + vaultV1PublicAllocator: "0xA090dD1a701408Df1d4d0B85b716c87565f90467", publicAllocator: "0xA090dD1a701408Df1d4d0B85b716c87565f90467", + vaultV2BluePublicAllocator: "0xAED282B8aD9257BB1272e93aE63A32A53621e412", metaMorphoFactory: "0xFf62A7c278C62eD665133147129245053Bbf5918", vaultV2Factory: "0x4501125508079A99ebBebCE205DeC9593C2b5857", morphoMarketV1AdapterFactory: "0x133baC94306B99f6dAD85c381a5be851d8DD717c", @@ -280,7 +292,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0x7Ae2B7012c82ea18a6BeE98ad09a684C88d6e36a", }, adaptiveCurveIrm: "0xe675A2161D4a6E2de2eeD70ac98EEBf257FBF0B0", + vaultV1PublicAllocator: "0xfac15aff53ADd2ff80C2962127C434E8615Df0d3", publicAllocator: "0xfac15aff53ADd2ff80C2962127C434E8615Df0d3", + vaultV2BluePublicAllocator: "0xAb06a92cd253Bc12Dec8f719a693a6b472CCDfF4", metaMorphoFactory: "0xa9c87daB340631C34BB738625C70499e29ddDC98", vaultV2Factory: "0xC11a53eE9B1eCc7a068D8e40F8F17926584F97Cf", morphoMarketV1AdapterFactory: "0xD1A0C86F28ecD1657Ad06415c2B230cC89D9b6dd", @@ -310,7 +324,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0x7B885a940164eD51A068725f577a12197b76109b", }, adaptiveCurveIrm: "0x66F30587FB8D4206918deb78ecA7d5eBbafD06DA", + vaultV1PublicAllocator: "0x769583Af5e9D03589F159EbEC31Cc2c23E8C355E", publicAllocator: "0x769583Af5e9D03589F159EbEC31Cc2c23E8C355E", + vaultV2BluePublicAllocator: "0x85b66Fe31e6788E5a6825EAe689f4c6c38AF3704", metaMorphoFactory: "0x878988f5f561081deEa117717052164ea1Ef0c82", vaultV2Factory: "0x6b46fa3cc9EBF8aB230aBAc664E37F2966Bf7971", morphoMarketV1AdapterFactory: "0x96456Bf888D4de607Bf3ca0b3C8e4DF9b0d0Ad47", @@ -338,7 +354,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0x80De0F063aC662a4ee86c2F4Db0b52746094ad62", }, adaptiveCurveIrm: "0x8cD70A8F399428456b29546BC5dBe10ab6a06ef6", + vaultV1PublicAllocator: "0x0d68a97324E602E02799CD83B42D337207B40658", publicAllocator: "0x0d68a97324E602E02799CD83B42D337207B40658", + vaultV2BluePublicAllocator: "0xc6945A915Bb7e2A365469f120A33D2FA42951cF3", metaMorphoFactory: "0x3Bb6A6A0Bc85b367EFE0A5bAc81c5E52C892839a", vaultV2Factory: "0x6128b680b277Bf4Df80DFE9D8c55A498660870ef", morphoMarketV1AdapterFactory: "0x65956d5Ba4974983ecCe111612FC0A0c22650A11", @@ -364,7 +382,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xcf7b4a40f25A6b839A93b8A8b45297F2a5383E73", }, adaptiveCurveIrm: "0x34E99D604751a72cF8d0CFDf87069292d82De472", + vaultV1PublicAllocator: "0xef9889B4e443DEd35FA0Bd060f2104Cca94e6A43", publicAllocator: "0xef9889B4e443DEd35FA0Bd060f2104Cca94e6A43", + vaultV2BluePublicAllocator: "0x5Fe47f63ACd84f8A69b97E0a5122fCBff08Df48F", metaMorphoFactory: "0x4DBB3a642a2146d5413750Cca3647086D9ba5F12", vaultV2Factory: "0x6846EA318B6B987Ee6b28eBFd87c3409F1d13108", morphoMarketV1AdapterFactory: "0xAf93F2d8508053432659d509b0210fdF1472493D", @@ -389,6 +409,7 @@ const _addressesRegistry = { generalAdapter1: "0x228dDF333DDf6D1895dA1dE8a846EDD27F1284eD", }, adaptiveCurveIrm: "0xA0D4D77b5D9933073572E19C172BFE866312673b", + vaultV1PublicAllocator: "0x37a888192165fC39884f87c64E2476BfD2C09675", publicAllocator: "0x37a888192165fC39884f87c64E2476BfD2C09675", metaMorphoFactory: "0x27D4Af0AC9E7FDfA6D0853236f249CC27AE79488", chainlinkOracleFactory: "0x39d8622C607A691D7705E8842fbB12E3c38dCD41", @@ -404,6 +425,7 @@ const _addressesRegistry = { generalAdapter1: "0xD2780fae0869cDc06EE202152304A39653361525", }, adaptiveCurveIrm: "0xa5EA7500A27C0079961D93366A6e93aafF18CB90", + vaultV1PublicAllocator: "0x8a7f671E45E51dE245649Cf916cA0256FB8a9927", publicAllocator: "0x8a7f671E45E51dE245649Cf916cA0256FB8a9927", metaMorphoFactory: "0x56b65742ade55015e6480959808229Ad6dbc9295", chainlinkOracleFactory: "0xb5961902E60b188b1c665B7b72Ef616656A9e24E", @@ -421,6 +443,7 @@ const _addressesRegistry = { generalAdapter1: "0xB8B2aDdCDe1cdC94AaE18a0F8A19df03D8683610", }, adaptiveCurveIrm: "0x9515407b1512F53388ffE699524100e7270Ee57B", + vaultV1PublicAllocator: "0x85416891752a6B81106c1C2999AE1AF5d8Cd3357", publicAllocator: "0x85416891752a6B81106c1C2999AE1AF5d8Cd3357", metaMorphoFactory: "0xd3f39505d0c48AFED3549D625982FdC38Ea9904b", chainlinkOracleFactory: "0x3FFFE273ee348b9E1ef89533025C7f165B17B439", @@ -444,7 +467,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0x0628B860947fA0c195988F65d53850546A489732", }, adaptiveCurveIrm: "0x9a6061d51743B31D2c3Be75D83781Fa423f53F0E", + vaultV1PublicAllocator: "0xB0c9a107fA17c779B3378210A7a593e88938C7C9", publicAllocator: "0xB0c9a107fA17c779B3378210A7a593e88938C7C9", + vaultV2BluePublicAllocator: "0x2b7Bf2f2027bcfE3A1F6Bc93EA80220a883a6851", metaMorphoFactory: "0xe9EdE3929F43a7062a007C3e8652e4ACa610Bdc0", vaultV2Factory: "0xC9b34c108014B44e5a189A830e7e04c56704a0c9", morphoMarketV1AdapterFactory: "0x117b92Ab1C025B175ED38a0CDe5A067a745224a0", @@ -467,6 +492,7 @@ const _addressesRegistry = { generalAdapter1: "0x31D5aee8D75EEab548cfA0d11C4f9843a5201eaf", }, adaptiveCurveIrm: "0xDEfCf242226425f93d8DD0e314735C28517C473F", + vaultV1PublicAllocator: "0x6Cef2EDC70D87E8f1623f3096efF05d066E59B36", publicAllocator: "0x6Cef2EDC70D87E8f1623f3096efF05d066E59B36", metaMorphoFactory: "0x0cE9e3512CB4df8ae7e265e62Fb9258dc14f12e8", chainlinkOracleFactory: "0x7DA59Fa482F1F49fADc486d8e47BADc506fEb86d", @@ -483,6 +509,7 @@ const _addressesRegistry = { generalAdapter1: "0x9623090C3943ad63F7d794378273610Dd0deeFD4", }, adaptiveCurveIrm: "0xdEbdEa31624552DF904A065221cD14088ABDeD70", + vaultV1PublicAllocator: "0x4107Ea1746909028d6212B315dE5fE9538F9eb39", publicAllocator: "0x4107Ea1746909028d6212B315dE5fE9538F9eb39", metaMorphoFactory: "0x8e52179BeB18E882040b01632440d8Ca0f01da82", chainlinkOracleFactory: "0xB3cb32E6185446a6Bc7A047E4FfA138fA939e133", @@ -500,6 +527,7 @@ const _addressesRegistry = { generalAdapter1: "0xF53925b95Cc409447066cd5c1A7756084b2Ee0a4", }, adaptiveCurveIrm: "0xE3d46Ae190Cb39ccA3655E966DcEF96b4eAe1d1c", + vaultV1PublicAllocator: "0xEE868Bf3359DA30c10ea472EAEBFC0a06E8F0120", publicAllocator: "0xEE868Bf3359DA30c10ea472EAEBFC0a06E8F0120", metaMorphoFactory: "0xae5b0884bfff430493D6C844B9fd052Af7d79278", chainlinkOracleFactory: "0xf9380f7898423Bd7FDe3C9fDD1b2671A2471f39D", @@ -515,6 +543,7 @@ const _addressesRegistry = { generalAdapter1: "0x464a402244bCDdc0c2091D5193E8ffdb2be54Ca9", }, adaptiveCurveIrm: "0x58a42117d753a0e69694545DfA19d64c2fB759fB", + vaultV1PublicAllocator: "0xDFde06e2B2A2D718eE5560b73dA4F830E56A2f10", publicAllocator: "0xDFde06e2B2A2D718eE5560b73dA4F830E56A2f10", metaMorphoFactory: "0xe430821595602eA5DD0cD350f86987437c7362fA", chainlinkOracleFactory: "0x16278156D366fC91536b6b81482ffaC47EEa06D6", @@ -532,6 +561,7 @@ const _addressesRegistry = { generalAdapter1: "0x65ff368930Cb7eB4CA5C5eBC58bb69E6Ed198BA5", }, adaptiveCurveIrm: "0x7420302Ddd469031Cd2282cd64225cCd46F581eA", + vaultV1PublicAllocator: "0x58485338D93F4e3b4Bf2Af1C9f9C0aDF087AEf1C", publicAllocator: "0x58485338D93F4e3b4Bf2Af1C9f9C0aDF087AEf1C", metaMorphoFactory: "0x2525D453D9BA13921D5aB5D8c12F9202b0e19456", vaultV2Factory: "0x4f0a370bb367843CFd914c4d9972523aD2f8FCc9", @@ -554,6 +584,7 @@ const _addressesRegistry = { generalAdapter1: "0x29dcA26F9862CFb8064163ddc3401aaB4D4D05c6", }, adaptiveCurveIrm: "0xd5661D965cc60ed1954d4f6725b766051De3ef97", + vaultV1PublicAllocator: "0x0b7a3A49dafd98363B428cEC966106f29c0eee75", publicAllocator: "0x0b7a3A49dafd98363B428cEC966106f29c0eee75", metaMorphoFactory: "0x3F4b9246b7Cd3F7671c70BeBd5AAFC08e5bb5f16", chainlinkOracleFactory: "0x391A3fd481743FE48409e2e31eDac8a5f4C7653A", @@ -574,7 +605,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xa434ABcc7e945b804c87B4f3c0a76b20651d4863", }, adaptiveCurveIrm: "0x4F708C0ae7deD3d74736594C2109C2E3c065B428", + vaultV1PublicAllocator: "0x39EB6Da5e88194C82B13491Df2e8B3E213eD2412", publicAllocator: "0x39EB6Da5e88194C82B13491Df2e8B3E213eD2412", + vaultV2BluePublicAllocator: "0xd952175e940D97775cBC5a523977a6f091D0d702", metaMorphoFactory: "0x1c8De6889acee12257899BFeAa2b7e534de32E16", vaultV2Factory: "0xFcb8b57E56787bB29e130Fca67f3c5a1232975D1", morphoMarketV1AdapterFactory: "0x2e6BE3a3A27fb45c6AbA2D1833eeA48E8788538e", @@ -595,6 +628,7 @@ const _addressesRegistry = { generalAdapter1: "0xEabdAC78A7f0a9B3dF0e23D69A5a5fF7f580a910", }, adaptiveCurveIrm: "0xC1523BE776e66ba07b609b1914D0925278f21FE5", + vaultV1PublicAllocator: "0x8b8B1bd41d36c06253203CD21463994aB752c1e6", publicAllocator: "0x8b8B1bd41d36c06253203CD21463994aB752c1e6", metaMorphoFactory: "0x997a79c3C04c5B9eb27d343ae126bcCFb5D74781", chainlinkOracleFactory: "0x12FA40f687a35611720E1DcB59976B6e51247298", @@ -611,6 +645,7 @@ const _addressesRegistry = { generalAdapter1: "0x6D94E7dCA6d8FAE2CF954633C2Cf9c286258E0af", }, adaptiveCurveIrm: "0x7E82b16496fA8CC04935528dA7F5A2C684A3C7A3", + vaultV1PublicAllocator: "0x414247afcf1fE3b94C617e7E3A7adB81D8D3208F", publicAllocator: "0x414247afcf1fE3b94C617e7E3A7adB81D8D3208F", metaMorphoFactory: "0xcDA78f4979d17Ec93052A84A12001fe0088AD734", chainlinkOracleFactory: "0xbf10eD52dD60C60E901BF022c3675303ad4a56b1", @@ -627,6 +662,7 @@ const _addressesRegistry = { generalAdapter1: "0x76cFE4BF840C7b461772fE7CDE399f58c4173584", }, adaptiveCurveIrm: "0x5576629f21D528A8c3e06C338dDa907B94563902", + vaultV1PublicAllocator: "0xb1E5B1De2a54ab55C412B5ee1E38e46799588103", publicAllocator: "0xb1E5B1De2a54ab55C412B5ee1E38e46799588103", metaMorphoFactory: "0x01dD876130690469F685a65C2B295A90a81BaD91", chainlinkOracleFactory: "0x2eb4D17C2AAf1EA62Bf83Fb49Dd1128b14AF4D93", @@ -647,7 +683,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xC1749C8d50bc645D5116ccf4C858Bc45cB981Ac4", }, adaptiveCurveIrm: "0xD4a426F010986dCad727e8dd6eed44cA4A9b7483", + vaultV1PublicAllocator: "0x517505be22D9068687334e69ae7a02fC77edf4Fc", publicAllocator: "0x517505be22D9068687334e69ae7a02fC77edf4Fc", + vaultV2BluePublicAllocator: "0x056dd7D4B373ED26c788190085CC6C52B8e7479d", metaMorphoFactory: "0xec051b19d654C48c357dC974376DeB6272f24e53", vaultV2Factory: "0xD7217E5687FF1071356C780b5fe4803D9D967da7", morphoMarketV1AdapterFactory: "0xc6b8B565C715134b0Ca3D6fa3D29B25759D0b9e2", @@ -671,6 +709,7 @@ const _addressesRegistry = { generalAdapter1: "0x02e0e71e145f254820B9D89c9E6068f08256F601", }, adaptiveCurveIrm: "0x6eFA8e3Aa8279eB2fd46b6083A9E52dA72EA56c4", + vaultV1PublicAllocator: "0xD878509446bE2C601f0f032F501851001B159D6B", publicAllocator: "0xD878509446bE2C601f0f032F501851001B159D6B", metaMorphoFactory: "0x8Dea49ec5bd5AeAc8bcf96B3E187F59354118291", chainlinkOracleFactory: "0x4bD68c2FF3274207EC07ED281C915758b6F23F07", @@ -686,6 +725,7 @@ const _addressesRegistry = { generalAdapter1: "0xFaD987d0EedfbAC709EC27ee4a94f36A1300a054", }, adaptiveCurveIrm: "0xf52e20C42FEc624819D4184226C4777D7cbd767e", + vaultV1PublicAllocator: "0x28a80F3713735CAD44bD9d7E1da9Fa16b0244497", publicAllocator: "0x28a80F3713735CAD44bD9d7E1da9Fa16b0244497", metaMorphoFactory: "0x41528AadC7314658b07Ca6e7213B9b77289B477f", chainlinkOracleFactory: "0x5115c1a74ABf096150593EecF3e20F016fc9dB43", @@ -701,6 +741,7 @@ const _addressesRegistry = { generalAdapter1: "0x454dAb6ce9891245696b239b4845a1cDC268255d", }, adaptiveCurveIrm: "0x85C2Ef4Bd69f42D7Da19Fb9dcdD7Fb8d0F59cDeE", + vaultV1PublicAllocator: "0x2d4cf00e18D48fD030d9b1E2FAAE6e0384C7610B", publicAllocator: "0x2d4cf00e18D48fD030d9b1E2FAAE6e0384C7610B", metaMorphoFactory: "0xA148a8223B622A72dC36472DE1492aBb5c089BA7", vaultV2Factory: "0x5DC11CF8BA4C39d1194F91218D35008d9F52A5d0", @@ -724,7 +765,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xB04b831893A6E2E02Be347cD259690c5Bc7D0675", }, adaptiveCurveIrm: "0x09475a3D6eA8c314c592b1a3799bDE044E2F400F", + vaultV1PublicAllocator: "0xfd70575B732F9482F4197FE1075492e114E97302", publicAllocator: "0xfd70575B732F9482F4197FE1075492e114E97302", + vaultV2BluePublicAllocator: "0x0A503aB026EFACBC0F7feE7795F34B80b5B9a662", metaMorphoFactory: "0x33f20973275B2F574488b18929cd7DCBf1AbF275", vaultV2Factory: "0x8B2F922162FBb60A6a072cC784A2E4168fB0bb0c", morphoMarketV1AdapterFactory: "0x8Da54fbF89B3D6fC6DCC92F31CF75a211ACF3d46", @@ -748,7 +791,9 @@ const _addressesRegistry = { vaultExitBundlesV1: "0x258d5c815CCE7017E24c63a7669F51ABcD0Dd4e5", }, adaptiveCurveIrm: "0x41e846FC8108b8527C1D4EDB4c9564E56442940f", + vaultV1PublicAllocator: "0xbCB063D4B6D479b209C186e462828CBACaC82DbE", publicAllocator: "0xbCB063D4B6D479b209C186e462828CBACaC82DbE", + vaultV2BluePublicAllocator: "0x5C884d4B1510EAd302EC50A2AB4DE9c0b9E407ce", metaMorphoFactory: "0xb4ae5673c48621189E2bEfBA96F31912032DD1AE", vaultV2Factory: "0x7fc35488803D49D00a94b206A223f7661898BE3a", morphoMarketV1AdapterFactory: "0x2A5F218FE4Dac3b1f4E096e8ae83074bB1713833", @@ -769,6 +814,7 @@ const _addressesRegistry = { generalAdapter1: "0xCa470cBBc3de56FDe336466f2107cC969174d513", }, adaptiveCurveIrm: "0x1Db002C086439d55B9f33E6c0693Eb850F7c0607", + vaultV1PublicAllocator: "0xFEAbEf95f3C937Ff4d5fD70005CF3392f8Ca02d5", publicAllocator: "0xFEAbEf95f3C937Ff4d5fD70005CF3392f8Ca02d5", metaMorphoFactory: "0xEA67e5566Ca2c0176d9db172A7f9A1e1F22E9D3A", vaultV2Factory: "0x05519a0835a1bFD90f110aA7ca46e9A5F81Ed3b4", @@ -791,6 +837,7 @@ const _addressesRegistry = { generalAdapter1: "0x3E7544a07157D03a49359eE89f2fCac9a6467230", }, adaptiveCurveIrm: "0x683CAAADdfA2F42e24880E202676526d501a5dED", + vaultV1PublicAllocator: "0x3Fe12193D178B76BaF4e23a083A64e49ACDE3188", publicAllocator: "0x3Fe12193D178B76BaF4e23a083A64e49ACDE3188", metaMorphoFactory: "0x6870aA9f66C1e5Efe8Dbe8730e86E9e91f688275", chainlinkOracleFactory: "0x3a4849b5174Dc6828c6Dc9BBD87e61Ed1ebE9fFA", @@ -806,6 +853,7 @@ const _addressesRegistry = { generalAdapter1: "0x3716AA06304D1bC70f553Da44904d13086A4a791", }, adaptiveCurveIrm: "0xd334eb112CfD1EB4a50FB871b7D9895EBB955C43", + vaultV1PublicAllocator: "0x609f3DF49806B5617A0Bd3301f04Ac3CB26d9e82", publicAllocator: "0x609f3DF49806B5617A0Bd3301f04Ac3CB26d9e82", metaMorphoFactory: "0x83A7f60c9fc57cEf1e8001bda98783AA1A53E4b1", chainlinkOracleFactory: "0x3585E3fD72F8d1b02250E1F6496b706c6e092884", @@ -822,6 +870,7 @@ const _addressesRegistry = { generalAdapter1: "0xa87F1422Df88B5f490203D71e2f8e7244843E62F", }, adaptiveCurveIrm: "0xefB565442B9Eb740B50Cf928C14d21c0111254F9", + vaultV1PublicAllocator: "0x3A1db0038361528756bED147abe3d41255c7128c", publicAllocator: "0x3A1db0038361528756bED147abe3d41255c7128c", metaMorphoFactory: "0xb95De4a9C81Ba6240378F383f88592d30937d048", chainlinkOracleFactory: "0xfDc69d06De855701731D142F28bD401802DA4daF", @@ -839,6 +888,7 @@ const _addressesRegistry = { generalAdapter1: "0x87c93660ECe6E68C6492EabBbBdbaafA102ae3a3", }, adaptiveCurveIrm: "0x7112D95cB5f6b13bF5F5B94a373bB3b2B381F979", + vaultV1PublicAllocator: "0x842bEccF8eBC11006c4bE96DEfE09b60326D0495", publicAllocator: "0x842bEccF8eBC11006c4bE96DEfE09b60326D0495", metaMorphoFactory: "0x92983687e672cA6d96530f9Dbe11a196cE905d72", chainlinkOracleFactory: "0xDf2035fC15919588526dBb5560863C812F135236", @@ -854,6 +904,7 @@ const _addressesRegistry = { generalAdapter1: "0xA47EeDE3Aac741B830E394B2e291f6774BD8bb48", }, adaptiveCurveIrm: "0x68F9b666b984527A7c145Db4103Cc6d3171C797F", + vaultV1PublicAllocator: "0x76f93A21573014Ab7d634D3204818922A234249e", publicAllocator: "0x76f93A21573014Ab7d634D3204818922A234249e", metaMorphoFactory: "0x7026b436f294e560b3C26E731f5cac5992cA2B33", chainlinkOracleFactory: "0x669F1A4cE3127740eCdB3E36adFC5Df6Db1EA74b", @@ -874,6 +925,7 @@ const _addressesRegistry = { vaultExitBundlesV1: "0x8225192b8638bDe9D41a6d96aBb824F660Ef57E1", }, adaptiveCurveIrm: "0x112fd4042E442C3C12C67AD23587b0afe36eB74E", + vaultV2BluePublicAllocator: "0xDC9693CE6488640faEf173Ec2635ff99fdC25a07", vaultV2Factory: "0x3DE400E3F79113194fa5AF6Ae5C474947E0C82Db", morphoMarketV1AdapterV2Factory: "0xF85aD5f14cC903533FC409B8098B58b4C2f36697", @@ -976,6 +1028,7 @@ const _addressesRegistry = { generalAdapter1: "0x8e36C2c6d7771820BF14a75f725f3cf0374a7823", }, adaptiveCurveIrm: "0xA4E2bA20Fc64D721D95BD5a28FF71844C5bb5cF2", + vaultV2BluePublicAllocator: "0x3b369B37eba1655e8c44bC08E3A604D592c4a14F", vaultV2Factory: "0xf2Aecd4a4d4C21d08770e34F392C4C271aBD9144", morphoMarketV1AdapterV2Factory: "0x4d04C39ca604b560c50F4045c558378FD9AEBCF4", @@ -1009,6 +1062,7 @@ const _addressesRegistry = { generalAdapter1: "0xcaeec65c85Fe964c8Bd814cb8E4CaF8B06bde776", }, adaptiveCurveIrm: "0xfB69467De332E03FF502B85bB2249d2f721F3319", + vaultV2BluePublicAllocator: "0x20d990D9eBf8003Df8cAD3Aa36aeF4404e3Ccb86", vaultV2Factory: "0x7D8BF8B276f967F7539c9e91E1a85a33fefE612B", morphoMarketV1AdapterV2Factory: "0xa01D7c41cf419405d4DF2e5750d26438DCAC28a6", @@ -1026,6 +1080,7 @@ const _addressesRegistry = { generalAdapter1: "0x74d3cbc721613C8461df92658d0a20dF275Ca31b", }, adaptiveCurveIrm: "0x56875764185548B0ca72A1877b3aE15E44e8A323", + vaultV2BluePublicAllocator: "0xB4A1B0EF18d169c19fC7617aCE898A06Dc495a7C", vaultV2Factory: "0xf133FA5A78C398B31Cc4a180E6Ae84111D6DCF5B", morphoMarketV1AdapterV2Factory: "0x00a58b7a9B3E86CB21f5F11f29F4A12346457012", @@ -1046,6 +1101,7 @@ const _addressesRegistry = { vaultExitBundlesV1: "0xCE29862924756584BBD0D75CA1249d22007E2813", }, adaptiveCurveIrm: "0x2BD3d5965B26B51814AC95127B2b80dD6CcC0fa1", + vaultV2BluePublicAllocator: "0xCe5c1aFa115fF8b1D6913509bfc79D9AE08CC857", vaultV2Factory: "0x0FBad98595b0186dA120E41f77C102beb49f803c", morphoMarketV1AdapterV2Factory: "0x79370Ed003CE325C088E530d5e8655c99c2993e1", @@ -1084,7 +1140,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 25_720_868n }, adaptiveCurveIrm: 18883124n, + vaultV1PublicAllocator: 19375099n, publicAllocator: 19375099n, + vaultV2BluePublicAllocator: 25770408n, metaMorphoFactory: 21439510n, vaultV2Factory: 23375073n, morphoMarketV1AdapterFactory: 23375073n, @@ -1108,7 +1166,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 49_765_458n }, adaptiveCurveIrm: 13977152n, + vaultV1PublicAllocator: 13979545n, publicAllocator: 13979545n, + vaultV2BluePublicAllocator: 50063965n, metaMorphoFactory: 23928808n, vaultV2Factory: 35615206n, morphoMarketV1AdapterFactory: 35615206n, @@ -1139,7 +1199,9 @@ const _deployments = { bundles: { vaultExitBundlesV1: 91_743_910n }, permit2: 35701901n, adaptiveCurveIrm: 66931042n, + vaultV1PublicAllocator: 66931042n, publicAllocator: 66931042n, + vaultV2BluePublicAllocator: 92141509n, metaMorphoFactory: 66931042n, vaultV2Factory: 77371907n, morphoMarketV1AdapterFactory: 77371907n, @@ -1162,7 +1224,9 @@ const _deployments = { bundles: { vaultExitBundlesV1: 492_901_559n }, permit2: 38692735n, adaptiveCurveIrm: 296446593n, + vaultV1PublicAllocator: 296446593n, publicAllocator: 296446593n, + vaultV2BluePublicAllocator: 495274087n, metaMorphoFactory: 296447195n, vaultV2Factory: 387016724n, morphoMarketV1AdapterFactory: 387016724n, @@ -1183,7 +1247,9 @@ const _deployments = { bundles: { vaultExitBundlesV1: 155_360_936n }, permit2: 38854427n, adaptiveCurveIrm: 130770075n, + vaultV1PublicAllocator: 130770075n, publicAllocator: 130770075n, + vaultV2BluePublicAllocator: 155659263n, metaMorphoFactory: 130770189n, vaultV2Factory: 142122059n, morphoMarketV1AdapterFactory: 142122059n, @@ -1202,7 +1268,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 33_492_822n }, adaptiveCurveIrm: 9025669n, + vaultV1PublicAllocator: 9025669n, publicAllocator: 9025669n, + vaultV2BluePublicAllocator: 33790828n, metaMorphoFactory: 9025733n, vaultV2Factory: 20253005n, morphoMarketV1AdapterFactory: 20253005n, @@ -1220,6 +1288,7 @@ const _deployments = { generalAdapter1: 16536231n, }, adaptiveCurveIrm: 15317931n, + vaultV1PublicAllocator: 15317931n, publicAllocator: 15317931n, metaMorphoFactory: 15318007n, chainlinkOracleFactory: 15318007n, @@ -1233,6 +1302,7 @@ const _deployments = { generalAdapter1: 13504587n, }, adaptiveCurveIrm: 12842868n, + vaultV1PublicAllocator: 12842868n, publicAllocator: 12842868n, metaMorphoFactory: 12842903n, chainlinkOracleFactory: 12842903n, @@ -1246,6 +1316,7 @@ const _deployments = { generalAdapter1: 6385077n, }, adaptiveCurveIrm: 4078776n, + vaultV1PublicAllocator: 4078776n, publicAllocator: 4078776n, metaMorphoFactory: 4078830n, chainlinkOracleFactory: 4078830n, @@ -1263,7 +1334,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 55_572_727n }, adaptiveCurveIrm: 9139027n, + vaultV1PublicAllocator: 9139027n, publicAllocator: 9139027n, + vaultV2BluePublicAllocator: 56168924n, metaMorphoFactory: 9316789n, vaultV2Factory: 29092109n, morphoMarketV1AdapterFactory: 29092109n, @@ -1281,6 +1354,7 @@ const _deployments = { generalAdapter1: 9102286n, }, adaptiveCurveIrm: 9100931n, + vaultV1PublicAllocator: 9100931n, publicAllocator: 9100931n, metaMorphoFactory: 9101319n, chainlinkOracleFactory: 9101319n, @@ -1294,6 +1368,7 @@ const _deployments = { generalAdapter1: 1188907n, }, adaptiveCurveIrm: 1188872n, + vaultV1PublicAllocator: 1188872n, publicAllocator: 1188872n, metaMorphoFactory: 1188885n, chainlinkOracleFactory: 1188885n, @@ -1307,6 +1382,7 @@ const _deployments = { generalAdapter1: 19983599n, }, adaptiveCurveIrm: 19983370n, + vaultV1PublicAllocator: 19983370n, publicAllocator: 19983370n, metaMorphoFactory: 19983443n, chainlinkOracleFactory: 19983443n, @@ -1320,6 +1396,7 @@ const _deployments = { generalAdapter1: 253107n, }, adaptiveCurveIrm: 251401n, + vaultV1PublicAllocator: 251401n, publicAllocator: 251401n, metaMorphoFactory: 253027n, chainlinkOracleFactory: 253027n, @@ -1333,6 +1410,7 @@ const _deployments = { generalAdapter1: 789925n, }, adaptiveCurveIrm: 765994n, + vaultV1PublicAllocator: 765994n, publicAllocator: 765994n, metaMorphoFactory: 766078n, vaultV2Factory: 32235414n, @@ -1351,6 +1429,7 @@ const _deployments = { generalAdapter1: 2471517n, }, adaptiveCurveIrm: 2410315n, + vaultV1PublicAllocator: 2410315n, publicAllocator: 2410315n, metaMorphoFactory: 2410440n, chainlinkOracleFactory: 2410440n, @@ -1365,7 +1444,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 39_579_123n }, adaptiveCurveIrm: 2741069n, + vaultV1PublicAllocator: 2741069n, publicAllocator: 2741069n, + vaultV2BluePublicAllocator: 40217302n, metaMorphoFactory: 2741420n, vaultV2Factory: 13096629n, morphoMarketV1AdapterFactory: 13096629n, @@ -1383,6 +1464,7 @@ const _deployments = { generalAdapter1: 21050766n, }, adaptiveCurveIrm: 21047448n, + vaultV1PublicAllocator: 21047448n, publicAllocator: 21047448n, metaMorphoFactory: 21050315n, chainlinkOracleFactory: 21050315n, @@ -1397,6 +1479,7 @@ const _deployments = { generalAdapter1: 978967n, }, adaptiveCurveIrm: 853025n, + vaultV1PublicAllocator: 853025n, publicAllocator: 853025n, metaMorphoFactory: 978654n, chainlinkOracleFactory: 978654n, @@ -1410,6 +1493,7 @@ const _deployments = { generalAdapter1: 15731595n, }, adaptiveCurveIrm: 15731231n, + vaultV1PublicAllocator: 15731231n, publicAllocator: 15731231n, metaMorphoFactory: 15731333n, chainlinkOracleFactory: 15731333n, @@ -1424,7 +1508,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 42_767_282n }, adaptiveCurveIrm: 1988429n, + vaultV1PublicAllocator: 1988429n, publicAllocator: 1988429n, + vaultV2BluePublicAllocator: 43372279n, metaMorphoFactory: 1988677n, vaultV2Factory: 14188393n, morphoMarketV1AdapterFactory: 14188393n, @@ -1443,6 +1529,7 @@ const _deployments = { }, permit2: 118721449n, adaptiveCurveIrm: 166036723n, + vaultV1PublicAllocator: 166036723n, publicAllocator: 166036723n, metaMorphoFactory: 168896078n, chainlinkOracleFactory: 168896078n, @@ -1457,6 +1544,7 @@ const _deployments = { generalAdapter1: 7527649n, }, adaptiveCurveIrm: 7526486n, + vaultV1PublicAllocator: 7526486n, publicAllocator: 7526486n, metaMorphoFactory: 7526768n, chainlinkOracleFactory: 7526768n, @@ -1470,6 +1558,7 @@ const _deployments = { generalAdapter1: 25072853n, }, adaptiveCurveIrm: 25072608n, + vaultV1PublicAllocator: 25072608n, publicAllocator: 25072608n, metaMorphoFactory: 25072665n, vaultV2Factory: 25072951n, @@ -1488,7 +1577,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 94_631_561n }, adaptiveCurveIrm: 31907457n, + vaultV1PublicAllocator: 31907457n, publicAllocator: 31907457n, + vaultV2BluePublicAllocator: 96602489n, metaMorphoFactory: 32320327n, vaultV2Factory: 32321811n, morphoMarketV1AdapterFactory: 32321811n, @@ -1508,7 +1599,9 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 34_970_501n }, adaptiveCurveIrm: 1504506n, + vaultV1PublicAllocator: 1504506n, publicAllocator: 1504506n, + vaultV2BluePublicAllocator: 35817019n, metaMorphoFactory: 1504774n, vaultV2Factory: 1506182n, morphoMarketV1AdapterFactory: 1506182n, @@ -1526,6 +1619,7 @@ const _deployments = { generalAdapter1: 38460388n, }, adaptiveCurveIrm: 38459435n, + vaultV1PublicAllocator: 38459435n, publicAllocator: 38459435n, metaMorphoFactory: 38459727n, vaultV2Factory: 38461643n, @@ -1544,6 +1638,7 @@ const _deployments = { generalAdapter1: 41808392n, }, adaptiveCurveIrm: 40249329n, + vaultV1PublicAllocator: 40249329n, publicAllocator: 40249329n, metaMorphoFactory: 40259931n, chainlinkOracleFactory: 40259931n, @@ -1558,6 +1653,7 @@ const _deployments = { generalAdapter1: 13949482n, }, adaptiveCurveIrm: 13947713n, + vaultV1PublicAllocator: 13947713n, publicAllocator: 13947713n, metaMorphoFactory: 13949369n, chainlinkOracleFactory: 13949369n, @@ -1572,6 +1668,7 @@ const _deployments = { generalAdapter1: 13638316n, }, adaptiveCurveIrm: 13516997n, + vaultV1PublicAllocator: 13516997n, publicAllocator: 13516997n, metaMorphoFactory: 13638155n, chainlinkOracleFactory: 13638155n, @@ -1587,6 +1684,7 @@ const _deployments = { generalAdapter1: 54346080n, }, adaptiveCurveIrm: 54344680n, + vaultV1PublicAllocator: 54344680n, publicAllocator: 54344680n, metaMorphoFactory: 54344985n, chainlinkOracleFactory: 54344985n, @@ -1600,6 +1698,7 @@ const _deployments = { generalAdapter1: 6443359n, }, adaptiveCurveIrm: 6440817n, + vaultV1PublicAllocator: 6440817n, publicAllocator: 6440817n, metaMorphoFactory: 6440899n, chainlinkOracleFactory: 6440899n, @@ -1614,6 +1713,7 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 34_046_873n }, adaptiveCurveIrm: 2375313n, + vaultV2BluePublicAllocator: 35177253n, vaultV2Factory: 2375650n, morphoMarketV1AdapterV2Factory: 2375701n, morphoVaultV1AdapterFactory: 16475630n, @@ -1710,6 +1810,7 @@ const _deployments = { generalAdapter1: 208021118n, }, adaptiveCurveIrm: 208021118n, + vaultV2BluePublicAllocator: 224866814n, vaultV2Factory: 213463014n, morphoMarketV1AdapterV2Factory: 213463079n, registryList: 213463079n, @@ -1741,6 +1842,7 @@ const _deployments = { generalAdapter1: 23180020n, }, adaptiveCurveIrm: 23180020n, + vaultV2BluePublicAllocator: 25455933n, vaultV2Factory: 23180183n, morphoMarketV1AdapterV2Factory: 23180228n, registryList: 23180228n, @@ -1757,6 +1859,7 @@ const _deployments = { generalAdapter1: 16408957n, }, adaptiveCurveIrm: 16408957n, + vaultV2BluePublicAllocator: 24269516n, vaultV2Factory: 16409067n, morphoMarketV1AdapterV2Factory: 16409115n, registryList: 16409115n, @@ -1774,6 +1877,7 @@ const _deployments = { }, bundles: { vaultExitBundlesV1: 32_383_480n }, adaptiveCurveIrm: 286n, + vaultV2BluePublicAllocator: 38318973n, vaultV2Factory: 288n, morphoMarketV1AdapterV2Factory: 289n, morphoVaultV1AdapterFactory: 58_781n, @@ -2282,7 +2386,14 @@ const refreshDeploymentViews = () => { blueDeployments = deployments; }; -const withBlueAlias = ({ +const withAliases = < + T extends { + blue?: unknown; + morpho?: unknown; + vaultV1PublicAllocator?: unknown; + publicAllocator?: unknown; + }, +>({ entry, label, type, @@ -2291,8 +2402,6 @@ const withBlueAlias = ({ label: string; type: string; }) => { - if (entry.blue == null && entry.morpho == null) return { ...entry } as T; - if ( entry.blue != null && entry.morpho != null && @@ -2309,10 +2418,42 @@ const withBlueAlias = ({ type, }); + if ( + entry.vaultV1PublicAllocator != null && + entry.publicAllocator != null && + !areRegistryValuesEqual({ + base: entry.vaultV1PublicAllocator, + patch: entry.publicAllocator, + type, + }) + ) + throw new RegistryValueAlreadyRegisteredError({ + label: `${label}.publicAllocator`, + registeredValue: isRegistryPrimitive(entry.vaultV1PublicAllocator) + ? entry.vaultV1PublicAllocator + : String(entry.vaultV1PublicAllocator), + requestedValue: isRegistryPrimitive(entry.publicAllocator) + ? entry.publicAllocator + : String(entry.publicAllocator), + type, + }); + return { ...entry, - blue: entry.blue ?? entry.morpho, - morpho: entry.morpho ?? entry.blue, + ...(entry.blue != null || entry.morpho != null + ? { + blue: entry.blue ?? entry.morpho, + morpho: entry.morpho ?? entry.blue, + } + : {}), + ...(entry.vaultV1PublicAllocator != null || entry.publicAllocator != null + ? { + vaultV1PublicAllocator: + entry.vaultV1PublicAllocator ?? entry.publicAllocator, + publicAllocator: + entry.publicAllocator ?? entry.vaultV1PublicAllocator, + } + : {}), } as T; }; @@ -2398,7 +2539,7 @@ export function registerCustomAddresses< const chainId = Number(chainIdString); const registeredEntry = nextRegistry[chainId]; const requestedEntry = cloneRegistryValue( - withBlueAlias({ + withAliases({ entry: requestedAddresses, label: String(chainId), type: "address", @@ -2439,7 +2580,7 @@ export function registerCustomAddresses< const chainId = Number(chainIdString); const registeredEntry = nextRegistry[chainId]; const requestedEntry = cloneRegistryValue( - withBlueAlias({ + withAliases({ entry: requestedDeployments, label: String(chainId), type: "deployment", diff --git a/packages/wdk-protocol-lending-morpho-evm/README.md b/packages/wdk-protocol-lending-morpho-evm/README.md index 425336244..0915d4f20 100644 --- a/packages/wdk-protocol-lending-morpho-evm/README.md +++ b/packages/wdk-protocol-lending-morpho-evm/README.md @@ -14,6 +14,7 @@ This module follows Wallet Development Kit lending protocol conventions and acce - Withdraw from Morpho Vaults V2. - Supply and withdraw collateral in Morpho Blue market. - Borrow and repay from a configured Morpho Blue market. +- Opt into Vault V2 BluePublicAllocator reallocations for borrow liquidity. - Expose Morpho SDK approval/signature/authorization requirements. - Quote costs before sending. - Works with standard EVM accounts and ERC-4337 smart accounts. @@ -93,7 +94,7 @@ For vault deposits and collateral supply, pass either `amount`, `nativeAmount`, | `getSupplyCollateralRequirements(options)` | Return SDK requirements for collateral supply | | `quoteSupplyCollateral(options, config?)` | Quote collateral supply | | `borrow(options, config?)` | Borrow from the configured market | -| `getBorrowRequirements(options)` | Return SDK authorization requirements for borrow | +| `getBorrowRequirements(options)` | Return SDK authorization requirements, plus a penalty-token approval for the Vault V2 opt-in type | | `quoteBorrow(options, config?)` | Quote borrow | | `repay(options, config?)` | Repay by assets, or pass `amount: 'max'` to repay current borrow shares | | `getRepayRequirements(options)` | Return SDK requirements for repay | @@ -138,6 +139,30 @@ Requirement entries are one of: Morpho SDK enforces a builder/executor invariant for bundled actions. For that reason, `onBehalfOf` and vault/collateral withdrawal `to` must equal the connected wallet address in this WDK adapter. +Existing `MorphoBorrowOptions` callers keep the Vault V1 reallocation input and +an authorization-only `getBorrowRequirements` result type. To include Vault V2 +BluePublicAllocator calls, type the options as +`MorphoBorrowWithVaultV2ReallocationsOptions`; this explicitly widens the +result to include the loan-token approval used for proportional penalty donations: + +```typescript +import type { MorphoBorrowWithVaultV2ReallocationsOptions } from '@morpho-org/wdk-protocol-lending-morpho-evm' + +const options = { + token: usdc, + amount: 1_000_000n, + reallocations: [{ + vault, + from: { type: 'idle' }, + to: { adapter }, + assets: 1_000_000n, + penalty: 1_000_000_000_000_000n + }] +} satisfies MorphoBorrowWithVaultV2ReallocationsOptions + +const requirements = await morpho.getBorrowRequirements(options) +``` + ## Fork E2E Test The regular unit test suite is fully mocked and runs as part of the workspace's `pnpm test` command. The Anvil-fork integration suite under `tests/integration/` is gated on `MAINNET_RPC_URL` being set; the corresponding tests are skipped otherwise. To execute the real vault deposit path against a mainnet fork: diff --git a/packages/wdk-protocol-lending-morpho-evm/src/index.ts b/packages/wdk-protocol-lending-morpho-evm/src/index.ts index 9d82ccb75..76627106f 100644 --- a/packages/wdk-protocol-lending-morpho-evm/src/index.ts +++ b/packages/wdk-protocol-lending-morpho-evm/src/index.ts @@ -2,6 +2,8 @@ export type { InputMarketParams } from "@morpho-org/blue-sdk"; export type { RequirementSignature, VaultReallocation, + VaultV1Reallocation, + VaultV2BlueReallocation, } from "@morpho-org/morpho-sdk"; export type { TransactionResult } from "@tetherto/wdk-wallet"; export type { @@ -29,6 +31,7 @@ export type { Erc4337TransactionConfig, MarketPosition, MorphoBorrowOptions, + MorphoBorrowWithVaultV2ReallocationsOptions, MorphoErc20SupplyOptions, MorphoEvmAccount, MorphoNativeSupplyOptions, diff --git a/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.test.ts b/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.test.ts index a4ee6d057..34c377c46 100644 --- a/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.test.ts +++ b/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.test.ts @@ -1,6 +1,17 @@ -import type { RequirementSignature } from "@morpho-org/morpho-sdk"; +import type { + RequirementSignature, + VaultReallocation, + VaultV2BlueReallocation, +} from "@morpho-org/morpho-sdk"; import * as viem from "viem"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import type { + MorphoBorrowOptions, + MorphoBorrowWithVaultV2ReallocationsOptions, + RequirementApproval, + RequirementAuthorization, + RequirementSignatureRequest, +} from "./morpho-protocol-evm.js"; const SEED = "cook voyage document eight skate token alien guide drink uncle term abuse"; @@ -448,6 +459,15 @@ describe.sequential("MorphoProtocolEvm", () => { }); describe("borrow", () => { + test("types: borrow reallocations require replayable arrays", () => { + expectTypeOf< + NonNullable + >().toEqualTypeOf(); + expectTypeOf< + MorphoBorrowWithVaultV2ReallocationsOptions["reallocations"] + >().toEqualTypeOf(); + }); + test("should build a market borrow with morpho-sdk and send it", async () => { account.sendTransaction = vi .fn() @@ -474,6 +494,36 @@ describe.sequential("MorphoProtocolEvm", () => { expect(result).toEqual({ hash: "dummy-borrow-hash", fee: 12_345n }); }); + test("should forward Vault V2 BluePublicAllocator reallocations", async () => { + const reallocation = { + vault: VAULT, + from: { type: "idle" }, + to: { + adapter: "0x0000000000000000000000000000000000000020", + }, + assets: 50_000n, + penalty: 1n, + } satisfies VaultV2BlueReallocation; + + account.sendTransaction = vi + .fn() + .mockResolvedValue({ hash: "dummy-v2-borrow-hash", fee: 12_345n }); + + await protocol.borrow({ + token: TOKEN, + amount: 100_000n, + reallocations: [reallocation], + }); + + expect(marketEntity.borrow).toHaveBeenCalledWith({ + amount: 100_000n, + userAddress: ADDRESS, + positionData, + slippageTolerance: undefined, + reallocations: [reallocation], + }); + }); + test("should fetch market params when only borrowMarketId is configured", async () => { // biome-ignore lint/suspicious/noShadow: test-local protocol shadowing the suite default const protocol = new MorphoProtocolEvm(account, { @@ -516,15 +566,50 @@ describe.sequential("MorphoProtocolEvm", () => { }); test("should return borrow requirements from morpho-sdk", async () => { - const requirements = await protocol.getBorrowRequirements({ + const options = { token: TOKEN, amount: 100_000n, - }); + } satisfies MorphoBorrowOptions; + const promise = protocol.getBorrowRequirements(options); + expectTypeOf(promise).toEqualTypeOf< + Promise<(RequirementAuthorization | RequirementSignatureRequest)[]> + >(); + const requirements = await promise; expect(requirements).toEqual([{ action: { type: "blueAuthorization" } }]); expect(borrowAction.getRequirements).toHaveBeenCalled(); }); + test("types: Vault V2 borrow requirements opt into approval results", async () => { + const options = { + token: TOKEN, + amount: 100_000n, + reallocations: [ + { + vault: VAULT, + from: { type: "idle" }, + to: { + adapter: "0x0000000000000000000000000000000000000020", + }, + assets: 50_000n, + penalty: 1n, + }, + ], + } satisfies MorphoBorrowWithVaultV2ReallocationsOptions; + + const promise = protocol.getBorrowRequirements(options); + expectTypeOf(promise).toEqualTypeOf< + Promise< + ( + | RequirementApproval + | RequirementAuthorization + | RequirementSignatureRequest + )[] + > + >(); + await promise; + }); + test("should build the borrow without signatures by default", async () => { account.sendTransaction = vi .fn() @@ -713,7 +798,6 @@ describe.sequential("MorphoProtocolEvm", () => { chainId: 1, provider: "https://dummy-rpc-url.com", bundlerUrl: "https://dummy-bundler-url.com", - entryPointAddress: "0x0000000000000000000000000000000000000007", safeModulesVersion: "0.3.0", isSponsored: false, useNativeCoins: true, diff --git a/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.ts b/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.ts index f4d126bba..c5f6287cb 100644 --- a/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.ts +++ b/packages/wdk-protocol-lending-morpho-evm/src/morpho-protocol-evm.ts @@ -14,6 +14,7 @@ import { type RequirementSignature, type Transaction, type VaultReallocation, + type VaultV2BlueReallocation, } from "@morpho-org/morpho-sdk"; import type { BorrowResult, @@ -156,7 +157,7 @@ export interface MorphoBorrowOptions { amount: number | bigint; /** The address on behalf of which the borrow operation should be performed. Must match the wallet account address when set. */ onBehalfOf?: string; - /** Optional Morpho Vault V2 reallocations to include in the borrow action. */ + /** Optional Vault V1 PublicAllocator reallocations to include in the borrow action. */ reallocations?: readonly VaultReallocation[]; /** Signature returned by a Morpho SDK authorization requirement, folded into the bundle as `setAuthorizationWithSig`. */ requirementSignature?: RequirementSignature; @@ -164,6 +165,25 @@ export interface MorphoBorrowOptions { slippageTolerance?: bigint; } +/** + * Borrow options that opt into Vault V2 BluePublicAllocator reallocations. + * + * Passing this type widens {@link MorphoProtocolEvm.getBorrowRequirements} to + * include the loan-token approval that a Vault V2 penalty may require. Legacy + * {@link MorphoBorrowOptions} callers retain the authorization-only result. + */ +export type MorphoBorrowWithVaultV2ReallocationsOptions = Omit< + MorphoBorrowOptions, + "reallocations" +> & { + /** Vault V2 BluePublicAllocator reallocations to include in the borrow action. */ + readonly reallocations: readonly VaultV2BlueReallocation[]; +}; + +type MorphoBorrowInput = + | MorphoBorrowOptions + | MorphoBorrowWithVaultV2ReallocationsOptions; + export interface MorphoRepayOptions { /** The address of the token to repay. */ token: string; @@ -653,7 +673,7 @@ export default class MorphoProtocolEvm extends LendingProtocol { * @throws {Error} If the options are invalid, GeneralAdapter1 is not authorized, or the transaction fails. */ async borrow( - options: MorphoBorrowOptions, + options: MorphoBorrowInput, config?: Erc4337TransactionConfig, ): Promise { this._assertWritable("borrow(options)"); @@ -664,17 +684,43 @@ export default class MorphoProtocolEvm extends LendingProtocol { } /** - * Returns Morpho SDK requirements for a borrow. + * Returns Morpho SDK authorization requirements for a borrow without Vault V2 reallocations. * * @param options - The borrow options. * @returns Authorization requirements. When offchain signatures are enabled - * (`supportSignature: true`), the authorization may instead be returned as a - * signable `RequirementSignatureRequest` to fold into the bundle via - * `setAuthorizationWithSig`. + * (`supportSignature: true`), the authorization may instead be returned as a signable + * `RequirementSignatureRequest` to fold into the bundle via `setAuthorizationWithSig`. */ - async getBorrowRequirements( + public getBorrowRequirements( options: MorphoBorrowOptions, - ): Promise<(RequirementAuthorization | RequirementSignatureRequest)[]> { + ): Promise<(RequirementAuthorization | RequirementSignatureRequest)[]>; + /** + * Returns Morpho SDK requirements for a borrow with Vault V2 reallocations. + * + * @param options - The Vault V2 reallocation borrow options. + * @returns Authorization requirements and any loan-token approval required for the public + * allocator penalty donation. When offchain signatures are enabled (`supportSignature: true`), + * the authorization may instead be returned as a signable `RequirementSignatureRequest` to + * fold into the bundle via `setAuthorizationWithSig`. + */ + public getBorrowRequirements( + options: MorphoBorrowWithVaultV2ReallocationsOptions, + ): Promise< + ( + | RequirementApproval + | RequirementAuthorization + | RequirementSignatureRequest + )[] + >; + public async getBorrowRequirements( + options: MorphoBorrowInput, + ): Promise< + ( + | RequirementApproval + | RequirementAuthorization + | RequirementSignatureRequest + )[] + > { const action = await this._getBorrowAction(options); return await action.getRequirements(); @@ -688,7 +734,7 @@ export default class MorphoProtocolEvm extends LendingProtocol { * @returns The fee quote. */ async quoteBorrow( - options: MorphoBorrowOptions, + options: MorphoBorrowInput, config?: Erc4337TransactionConfig, ): Promise> { const tx = await this._getBorrowTransaction(options); @@ -702,7 +748,7 @@ export default class MorphoProtocolEvm extends LendingProtocol { onBehalfOf, slippageTolerance, reallocations, - }: MorphoBorrowOptions) { + }: MorphoBorrowInput) { const normalizedAmount = normalizeAmount(amount); this._assertAddress("token", token); this._assertOptionalAddress("onBehalfOf", onBehalfOf); @@ -728,7 +774,7 @@ export default class MorphoProtocolEvm extends LendingProtocol { } private async _getBorrowTransaction( - options: MorphoBorrowOptions, + options: MorphoBorrowInput, ): Promise { const action = await this._getBorrowAction(options);