diff --git a/.changeset/vault-v2-accrue-nested-entities.md b/.changeset/vault-v2-accrue-nested-entities.md new file mode 100644 index 000000000..c78fd8ca4 --- /dev/null +++ b/.changeset/vault-v2-accrue-nested-entities.md @@ -0,0 +1,8 @@ +--- +"@morpho-org/blue-sdk": minor +"@morpho-org/blue-sdk-viem": patch +"@morpho-org/morpho-sdk": patch +"@morpho-org/wdk-protocol-lending-morpho-evm": patch +--- + +Accrue every underlying adapter, market, and position when accruing a Vault V2, so `AccrualVaultV2.accrueInterest` returns an entity graph whose entire nested state shares one `lastUpdate` — aligning Vault V2 with the existing MetaMorpho V1 (`AccrualVault.accrueInterest`) behavior, rather than leaving nested adapters at pre-accrual state. Adds a backward-compatible optional `accrueInterest(timestamp)` method to the `IAccrualVaultV2Adapter` interface — adapters that do not implement it are left at their pre-accrual state — and implements it on each built-in adapter. Vault-level `_totalAssets`, `totalSupply`, and fee shares are byte-for-byte unchanged. diff --git a/packages/blue-sdk/src/vault/v2/VaultV2.test.ts b/packages/blue-sdk/src/vault/v2/VaultV2.test.ts index 4fd135a04..fcce42c96 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2.test.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2.test.ts @@ -49,9 +49,10 @@ function adapterBaseInput(): Omit { function accrualAdapter( overrides: Partial = {}, ): IAccrualVaultV2Adapter { - return { + const adapter: IAccrualVaultV2Adapter = { ...vaultV2AdapterInput({ type: "AccrualAdapter" }), realAssets: () => 1_100n, + accrueInterest: () => adapter, maxDeposit: (_data, assets) => ({ value: BigInt(assets), limiter: CapacityLimitReason.balance, @@ -62,6 +63,7 @@ function accrualAdapter( }), ...overrides, }; + return adapter; } function accrualVaultV2( @@ -323,6 +325,55 @@ describe("AccrualVaultV2.accrueInterest", () => { expect(result.managementFeeShares).toBe(0n); expect(result.vault.totalSupply).toBe(1_000n + result.performanceFeeShares); }); + + test("behavior: accrues nested adapters and the liquidity adapter to the same timestamp", () => { + const adapter = new AccrualVaultV2MorphoMarketV1Adapter( + { + ...adapterBaseInput(), + marketParamsList: [new MarketParams(marketParams())], + }, + [accrualPosition({ supplyShares: 100n })], + ); + const vault = accrualVaultV2(adapter); + + const { vault: accrued } = vault.accrueInterest(101n); + + expect(accrued.lastUpdate).toBe(101n); + + const accruedAdapter = accrued + .accrualAdapters[0] as AccrualVaultV2MorphoMarketV1Adapter; + const accruedLiquidity = + accrued.accrualLiquidityAdapter as AccrualVaultV2MorphoMarketV1Adapter; + expect(accruedAdapter).not.toBe(adapter); + expect(accruedAdapter.positions[0]?.market.lastUpdate).toBe(101n); + expect(accruedLiquidity.positions[0]?.market.lastUpdate).toBe(101n); + + // Accrual is non-mutating: the source adapter keeps its original state. + expect(adapter.positions[0]?.market.lastUpdate).toBe(100n); + }); + + test("behavior: leaves adapters without accrueInterest at their pre-accrual state", () => { + // An adapter built before the optional `accrueInterest` method existed. + const legacyAdapter: IAccrualVaultV2Adapter = { + ...vaultV2AdapterInput({ type: "LegacyAdapter" }), + realAssets: () => 1_100n, + maxDeposit: (_data, assets) => ({ + value: BigInt(assets), + limiter: CapacityLimitReason.balance, + }), + maxWithdraw: () => ({ + value: 500n, + limiter: CapacityLimitReason.balance, + }), + }; + const vault = accrualVaultV2(legacyAdapter); + + const { vault: accrued } = vault.accrueInterest(101n); + + expect(accrued.lastUpdate).toBe(101n); + expect(accrued.accrualAdapters[0]).toBe(legacyAdapter); + expect(accrued.accrualLiquidityAdapter).toBe(legacyAdapter); + }); }); describe("VaultV2Adapter", () => { @@ -403,6 +454,21 @@ describe("AccrualVaultV2MorphoMarketV1Adapter", () => { limiter: CapacityLimitReason.position, }); }); + + test("accrueInterest accrues underlying positions and preserves realAssets", () => { + const position = accrualPosition({ supplyShares: 100n }); + const adapter = new AccrualVaultV2MorphoMarketV1Adapter( + { ...adapterBaseInput(), marketParamsList: [position.market.params] }, + [position], + ); + + const accrued = adapter.accrueInterest(101n); + + expect(accrued).not.toBe(adapter); + expect(accrued.positions[0]?.market.lastUpdate).toBe(101n); + expect(adapter.positions[0]?.market.lastUpdate).toBe(100n); + expect(accrued.realAssets(101n)).toBe(adapter.realAssets(101n)); + }); }); describe("VaultV2MorphoMarketV1AdapterV2", () => { @@ -518,6 +584,26 @@ describe("AccrualVaultV2MorphoMarketV1AdapterV2", () => { limiter: CapacityLimitReason.position, }); }); + + test("accrueInterest accrues underlying markets and preserves realAssets", () => { + const m = market(); + const adapter = new AccrualVaultV2MorphoMarketV1AdapterV2( + { + ...adapterBaseInput(), + marketIds: [m.id], + adaptiveCurveIrm: ADAPTER, + supplyShares: { [m.id]: 100n }, + }, + [m], + ); + + const accrued = adapter.accrueInterest(101n); + + expect(accrued).not.toBe(adapter); + expect(accrued.markets[0]?.lastUpdate).toBe(101n); + expect(adapter.markets[0]?.lastUpdate).toBe(100n); + expect(accrued.realAssets(101n)).toBe(adapter.realAssets(101n)); + }); }); describe("VaultV2MorphoVaultV1Adapter", () => { @@ -567,4 +653,29 @@ describe("AccrualVaultV2MorphoVaultV1Adapter", () => { limiter: CapacityLimitReason.liquidity, }); }); + + test("accrueInterest delegates to the underlying V1 vault's accrueInterest", () => { + const accruedVaultV1 = { + toAssets: (shares: bigint) => shares, + } as AccrualVault; + let accruedAt: bigint | undefined; + const accrualVaultV1 = { + accrueInterest: (timestamp?: bigint) => { + accruedAt = timestamp; + return accruedVaultV1; + }, + } as AccrualVault; + const adapter = new AccrualVaultV2MorphoVaultV1Adapter( + { ...adapterBaseInput(), morphoVaultV1: RECIPIENT }, + accrualVaultV1, + 10n, + ); + + const accrued = adapter.accrueInterest(5n); + + expect(accrued).not.toBe(adapter); + expect(accruedAt).toBe(5n); + expect(accrued.accrualVaultV1).toBe(accruedVaultV1); + expect(accrued.shares).toBe(10n); + }); }); diff --git a/packages/blue-sdk/src/vault/v2/VaultV2.ts b/packages/blue-sdk/src/vault/v2/VaultV2.ts index 41ae77f8e..e1c16892d 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2.ts @@ -233,19 +233,37 @@ export class AccrualVaultV2 extends VaultV2 implements IAccrualVaultV2 { } /** - * Returns a new vault derived from this vault, whose interest has been accrued up to the given timestamp. - * Performance and management fee shares are zero when the corresponding fee recipient cannot receive vault shares. - * @param timestamp The timestamp at which to accrue interest. Must be greater than or equal to the vault's `lastUpdate`. + * Returns a new vault derived from this vault, whose interest — together with + * that of every adapter, market, and position it holds — has been accrued up to + * the given timestamp, so the entire returned entity graph shares one + * `lastUpdate`. Adapters that do not implement `accrueInterest` are left at + * their pre-accrual state. + * Performance and management fee shares are zero when the corresponding fee + * recipient cannot receive vault shares. + * @param timestamp The timestamp at which to accrue interest. Must be greater + * than or equal to the vault's `lastUpdate` and to each underlying market's + * `lastUpdate`. + * @returns An object with the accrued `vault` (a new `AccrualVaultV2`) and the + * `performanceFeeShares` and `managementFeeShares` minted by the accrual. + * @throws {VaultV2Errors.InvalidInterestAccrual} when `timestamp` precedes the + * vault's `lastUpdate`. + * @throws {BlueErrors.InvalidInterestAccrual} when `timestamp` precedes an + * underlying market's `lastUpdate`. + * @example + * ```ts + * import { createPublicClient, http } from "viem"; + * import { mainnet } from "viem/chains"; + * import { fetchAccrualVaultV2 } from "@morpho-org/blue-sdk-viem"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }); + * const vault = await fetchAccrualVaultV2(vaultAddress, client); + * const { vault: accrued, performanceFeeShares } = vault.accrueInterest( + * vault.lastUpdate, + * ); + * // accrued.toAssets(accrued.totalSupply) reflects assets at vault.lastUpdate + * ``` */ public accrueInterest(timestamp: BigIntish) { - const vault = new AccrualVaultV2( - this, - this.accrualLiquidityAdapter, - this.accrualAdapters, - this.assetBalance, - this.forceDeallocatePenalties, - ); - // biome-ignore lint/style/noParameterAssign: TODO refactor to avoid mutating parameter timestamp = BigInt(timestamp); @@ -257,6 +275,25 @@ export class AccrualVaultV2 extends VaultV2 implements IAccrualVaultV2 { this.lastUpdate, ); + // Accrue every nested adapter (and the liquidity adapter) to the same + // timestamp, so the returned vault exposes an entity graph that shares one + // `lastUpdate` rather than pre-accrual market state. Adapters that predate + // `accrueInterest` are left as-is for backward compatibility. + const accrualAdapters = this.accrualAdapters.map( + (adapter) => adapter.accrueInterest?.(timestamp) ?? adapter, + ); + const accrualLiquidityAdapter = + this.accrualLiquidityAdapter?.accrueInterest?.(timestamp) ?? + this.accrualLiquidityAdapter; + + const vault = new AccrualVaultV2( + this, + accrualLiquidityAdapter, + accrualAdapters, + this.assetBalance, + this.forceDeallocatePenalties, + ); + // Corresponds to the `firstTotalAssets == 0` onchain check. if (elapsed === 0n) return { vault, performanceFeeShares: 0n, managementFeeShares: 0n }; @@ -300,7 +337,7 @@ export class AccrualVaultV2 extends VaultV2 implements IAccrualVaultV2 { vault._totalAssets = newTotalAssets; if (performanceFeeShares) vault.totalSupply += performanceFeeShares; if (managementFeeShares) vault.totalSupply += managementFeeShares; - vault.lastUpdate = BigInt(timestamp); + vault.lastUpdate = timestamp; return { vault, performanceFeeShares, managementFeeShares }; } diff --git a/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts b/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts index e701bb5e8..dcea3a349 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts @@ -38,6 +38,36 @@ export abstract class VaultV2Adapter implements IVaultV2Adapter { export interface IAccrualVaultV2Adapter extends IVaultV2Adapter { realAssets(timestamp: BigIntish): bigint; + /** + * Returns a new adapter derived from this one, whose underlying market state + * has been accrued up to the given timestamp. Lets a fully-accrued vault expose + * an entity graph in which every adapter, market, and position shares one + * `lastUpdate` instead of pre-accrual state. + * + * Optional for backward compatibility: an adapter that does not implement it is + * left at its pre-accrual state by the vault's `accrueInterest`. + * @param timestamp The timestamp at which to accrue interest. Required so every + * nested market accrues to the same instant. Must be greater than or equal to + * each underlying market's `lastUpdate`. + * @returns A new adapter of the same concrete type, with every underlying + * market accrued to `timestamp`. + * @throws {BlueErrors.InvalidInterestAccrual} when `timestamp` precedes an + * underlying market's `lastUpdate`. + * @example + * ```ts + * import { createPublicClient, http } from "viem"; + * import { mainnet } from "viem/chains"; + * import { fetchAccrualVaultV2 } from "@morpho-org/blue-sdk-viem"; + * + * const client = createPublicClient({ chain: mainnet, transport: http() }); + * const vault = await fetchAccrualVaultV2(vaultAddress, client); + * const [adapter] = vault.accrualAdapters; + * const accrued = adapter?.accrueInterest(vault.lastUpdate); + * // accrued.realAssets(vault.lastUpdate) reflects state at the shared timestamp + * ``` + */ + accrueInterest?(timestamp: BigIntish): IAccrualVaultV2Adapter; + /** * Returns the maximum amount of assets that can be deposited to this adapter. * @param assets The maximum amount of assets to deposit. diff --git a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts index 93d5cc712..062eaf7e9 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts @@ -104,6 +104,28 @@ export class AccrualVaultV2MorphoMarketV1Adapter ); } + /** + * Returns a new adapter whose underlying positions (and their markets) have + * been accrued up to the given timestamp. + * @param timestamp The timestamp at which to accrue interest. Must be greater + * than or equal to each position's market `lastUpdate`. + * @returns A new `AccrualVaultV2MorphoMarketV1Adapter` with every position + * accrued to `timestamp`. + * @throws {BlueErrors.InvalidInterestAccrual} when `timestamp` precedes a + * position's market `lastUpdate`. + * @example + * ```ts + * const accrued = adapter.accrueInterest(adapter.positions[0]!.market.lastUpdate); + * // accrued.positions[0]!.market.lastUpdate === the passed timestamp + * ``` + */ + accrueInterest(timestamp: BigIntish) { + return new AccrualVaultV2MorphoMarketV1Adapter( + this, + this.positions.map((position) => position.accrueInterest(timestamp)), + ); + } + maxDeposit(_data: Hex, assets: BigIntish) { return { value: BigInt(assets), diff --git a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts index fa243d5b1..a87bf1747 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1AdapterV2.ts @@ -114,6 +114,28 @@ export class AccrualVaultV2MorphoMarketV1AdapterV2 ); } + /** + * Returns a new adapter whose underlying markets have been accrued up to the + * given timestamp. + * @param timestamp The timestamp at which to accrue interest. Must be greater + * than or equal to each market's `lastUpdate`. + * @returns A new `AccrualVaultV2MorphoMarketV1AdapterV2` with every market + * accrued to `timestamp`. + * @throws {BlueErrors.InvalidInterestAccrual} when `timestamp` precedes a + * market's `lastUpdate`. + * @example + * ```ts + * const accrued = adapter.accrueInterest(adapter.markets[0]!.lastUpdate); + * // accrued.markets[0]!.lastUpdate === the passed timestamp + * ``` + */ + accrueInterest(timestamp: BigIntish) { + return new AccrualVaultV2MorphoMarketV1AdapterV2( + this, + this.markets.map((market) => market.accrueInterest(timestamp)), + ); + } + maxDeposit(_data: Hex, assets: BigIntish) { return { value: BigInt(assets), diff --git a/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts b/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts index 8510a0a39..2353bf9c2 100644 --- a/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts +++ b/packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts @@ -74,6 +74,29 @@ export class AccrualVaultV2MorphoVaultV1Adapter return this.accrualVaultV1.accrueInterest(timestamp).toAssets(this.shares); } + /** + * Returns a new adapter whose underlying MetaMorpho V1 vault (and its market + * positions) has been accrued up to the given timestamp. + * @param timestamp The timestamp at which to accrue interest. Must be greater + * than or equal to each underlying market's `lastUpdate`. + * @returns A new `AccrualVaultV2MorphoVaultV1Adapter` wrapping the V1 vault + * accrued to `timestamp`. + * @throws {BlueErrors.InvalidInterestAccrual} when `timestamp` precedes an + * underlying market's `lastUpdate`. + * @example + * ```ts + * const accrued = adapter.accrueInterest(timestamp); + * // accrued.realAssets(timestamp) reflects the V1 vault's assets at `timestamp` + * ``` + */ + accrueInterest(timestamp: BigIntish) { + return new AccrualVaultV2MorphoVaultV1Adapter( + this, + this.accrualVaultV1.accrueInterest(timestamp), + this.shares, + ); + } + maxDeposit(_data: Hex, assets: BigIntish) { return this.accrualVaultV1.maxDeposit(assets); }