Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/vault-v2-accrue-nested-entities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@morpho-org/blue-sdk": minor
Comment thread
Foulks-Plb marked this conversation as resolved.
"@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.
113 changes: 112 additions & 1 deletion packages/blue-sdk/src/vault/v2/VaultV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ function adapterBaseInput(): Omit<IVaultV2Adapter, "adapterId" | "type"> {
function accrualAdapter(
overrides: Partial<IAccrualVaultV2Adapter> = {},
): IAccrualVaultV2Adapter {
return {
const adapter: IAccrualVaultV2Adapter = {
...vaultV2AdapterInput({ type: "AccrualAdapter" }),
realAssets: () => 1_100n,
accrueInterest: () => adapter,
maxDeposit: (_data, assets) => ({
value: BigInt(assets),
limiter: CapacityLimitReason.balance,
Expand All @@ -62,6 +63,7 @@ function accrualAdapter(
}),
...overrides,
};
return adapter;
}

function accrualVaultV2(
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
61 changes: 49 additions & 12 deletions packages/blue-sdk/src/vault/v2/VaultV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 };
Expand Down Expand Up @@ -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 };
}
Expand Down
30 changes: 30 additions & 0 deletions packages/blue-sdk/src/vault/v2/VaultV2Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions packages/blue-sdk/src/vault/v2/VaultV2MorphoMarketV1Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions packages/blue-sdk/src/vault/v2/VaultV2MorphoVaultV1Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down