Skip to content

Commit 7b6610f

Browse files
committed
fix: bugs
1 parent 1ffbd96 commit 7b6610f

7 files changed

Lines changed: 154 additions & 80 deletions

File tree

docs/algorithm.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -426,15 +426,52 @@ function computeWithdrawalImpact(markets, idleAssets, withdrawAmount):
426426
currentApy = weightedVaultApy(markets)
427427
{ sim, withdrawable, remaining } = simulateWithdrawal(markets, withdrawAmount, idleAssets)
428428
429-
totalVaultAssets = sum(m.vaultSupplyAssets for m in markets) + idleAssets
430-
431-
if withdrawable >= totalVaultAssets:
432-
newApy = 0
433-
else:
434-
newApy = weightedVaultApy(markets, withdrawalSim=sim)
429+
newApy = weightedVaultApy(markets, withdrawalSim=sim)
430+
// Full drain: all vault weights become 0 → weightedVaultApy returns 0 naturally
435431
436432
impact = newApy - currentApy
437433
impactBps = round(impact * 10000)
438434
isPartial = remaining > 0
439435
return { currentApy, newApy, impact, impactBps, isPartial, withdrawableAmount: withdrawable }
440436
```
437+
438+
## 8. Interest Accrual
439+
440+
Morpho Blue accrues interest lazily — `totalSupplyAssets` and `totalBorrowAssets` are only updated on-chain when someone interacts with the market. The `market()` view function returns stored (potentially stale) state.
441+
442+
When fetching market data, the library computes accrued interest locally to correct for staleness:
443+
444+
```pseudocode
445+
function accrueMarketInterest(market, now):
446+
elapsed = now - market.lastUpdate
447+
if elapsed <= 0 or market.totalBorrowAssets == 0:
448+
return unchanged
449+
450+
borrowRate = computeBorrowRate(market.rateAtTarget, market.totalSupplyAssets, market.totalBorrowAssets)
451+
interest = market.totalBorrowAssets * taylorCompounded(borrowRate, elapsed) / WAD
452+
feeAmount = interest * market.fee / WAD
453+
454+
market.totalBorrowAssets += interest
455+
market.totalSupplyAssets += interest
456+
market.totalSupplyShares += feeAmount * market.totalSupplyShares / (market.totalSupplyAssets - feeAmount)
457+
```
458+
459+
Where `taylorCompounded(rate, n)` is a 3rd-order Taylor approximation of `exp(rate * n) - 1`, matching the on-chain implementation.
460+
461+
## 9. Limitations
462+
463+
### 9.1 Point-in-Time rateAtTarget
464+
465+
The Morpho Blue IRM is adaptive: `rateAtTarget` drifts over time based on utilization history. When utilization is above the 90% target, `rateAtTarget` increases; when below, it decreases. The adjustment speed is approximately 50x/year.
466+
467+
All simulations in this library use the **current** `rateAtTarget` value. A large deposit that pushes utilization well below target would, over time, cause `rateAtTarget` to decrease further, compounding the APY reduction beyond the point-in-time estimate. Conversely, a large withdrawal pushing utilization above target would cause `rateAtTarget` to increase.
468+
469+
For small deposits/withdrawals relative to market size, this drift is negligible. For large position changes, the true long-term impact will differ from the instantaneous estimate.
470+
471+
### 9.2 Floating-Point Precision
472+
473+
See section 1.1 for details on numerical precision. The library uses JavaScript `number` (IEEE-754 double) for APY math after converting on-chain `bigint` values. This is sufficient for all practical vault sizes but loses precision for raw balances exceeding `2^53` base units.
474+
475+
### 9.3 Continuous Compounding Approximation
476+
477+
The library uses `exp(rate * time) - 1` for APY computation, while Morpho on-chain uses a 3rd-order Taylor approximation. Both approximate continuous compounding and differ by less than `10^-8` for realistic rates.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "origin-morpho-utils",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Morpho Blue / MetaMorpho vault APY computation and deposit/withdrawal impact simulation",
55
"license": "BUSL-1.1",
66
"author": "Origin Protocol",

src/fetch.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,61 @@ import type { MarketForApy, VaultApyResult, VaultFetchResult } from './types.js'
1313

1414
const ADDRESS_ZERO = '0x0000000000000000000000000000000000000000'
1515

16+
// --- Interest accrual helpers (matches Morpho Blue on-chain logic) ---
17+
18+
const WAD_BI = 10n ** 18n
19+
const TARGET_UTIL_WAD = (9n * WAD_BI) / 10n // 0.9e18
20+
const STEEPNESS_WAD = 4n * WAD_BI // 4e18
21+
22+
function computeBorrowRateWad(rateAtTarget: bigint, totalSupply: bigint, totalBorrow: bigint): bigint {
23+
if (totalSupply === 0n || rateAtTarget <= 0n) return 0n
24+
const util = (totalBorrow * WAD_BI) / totalSupply
25+
const err =
26+
util > TARGET_UTIL_WAD
27+
? ((util - TARGET_UTIL_WAD) * WAD_BI) / (WAD_BI - TARGET_UTIL_WAD)
28+
: ((util - TARGET_UTIL_WAD) * WAD_BI) / TARGET_UTIL_WAD
29+
const coeff = err < 0n ? WAD_BI - (WAD_BI * WAD_BI) / STEEPNESS_WAD : STEEPNESS_WAD - WAD_BI
30+
return (((coeff * err) / WAD_BI + WAD_BI) * rateAtTarget) / WAD_BI
31+
}
32+
33+
function wTaylorCompounded(rate: bigint, elapsed: bigint): bigint {
34+
const firstTerm = rate * elapsed
35+
const secondTerm = (firstTerm * firstTerm) / (2n * WAD_BI)
36+
const thirdTerm = (secondTerm * firstTerm) / (3n * WAD_BI)
37+
return firstTerm + secondTerm + thirdTerm
38+
}
39+
40+
function accrueMarketInterest(
41+
totalSupplyAssets: bigint,
42+
totalSupplyShares: bigint,
43+
totalBorrowAssets: bigint,
44+
fee: bigint,
45+
rateAtTarget: bigint,
46+
lastUpdate: bigint,
47+
now: bigint,
48+
): { totalSupplyAssets: bigint; totalBorrowAssets: bigint; totalSupplyShares: bigint } {
49+
const elapsed = now - lastUpdate
50+
if (elapsed <= 0n || totalBorrowAssets === 0n) {
51+
return { totalSupplyAssets, totalBorrowAssets, totalSupplyShares }
52+
}
53+
const borrowRate = computeBorrowRateWad(rateAtTarget, totalSupplyAssets, totalBorrowAssets)
54+
if (borrowRate <= 0n) {
55+
return { totalSupplyAssets, totalBorrowAssets, totalSupplyShares }
56+
}
57+
const interest = (totalBorrowAssets * wTaylorCompounded(borrowRate, elapsed)) / WAD_BI
58+
if (interest === 0n) {
59+
return { totalSupplyAssets, totalBorrowAssets, totalSupplyShares }
60+
}
61+
const feeAmount = (interest * fee) / WAD_BI
62+
const newBorrow = totalBorrowAssets + interest
63+
const newSupply = totalSupplyAssets + interest
64+
let newShares = totalSupplyShares
65+
if (feeAmount > 0n && newSupply > feeAmount) {
66+
newShares += (feeAmount * totalSupplyShares) / (newSupply - feeAmount)
67+
}
68+
return { totalSupplyAssets: newSupply, totalBorrowAssets: newBorrow, totalSupplyShares: newShares }
69+
}
70+
1671
/**
1772
* Fetch all market data for a MetaMorpho vault using viem multicall.
1873
*
@@ -172,15 +227,27 @@ export async function fetchVaultMarketsViem(
172227
}
173228
}
174229

175-
// 6. Assemble
230+
// 6. Assemble (with interest accrual to correct stale on-chain state)
231+
const now = BigInt(Math.floor(Date.now() / 1000))
176232
const markets: MarketForApy[] = allIds.map((marketId, i) => {
177-
const [totalSupplyAssets, totalSupplyShares, totalBorrowAssets, , , fee] = marketStates[i]
233+
const [rawSupplyAssets, rawSupplyShares, rawBorrowAssets, , lastUpdate, fee] = marketStates[i]
178234
const [supplyShares] = positions[i]
179235
const [cap] = configs[i]
180236

237+
// Accrue interest since lastUpdate to get true current state
238+
const accrued = accrueMarketInterest(
239+
rawSupplyAssets,
240+
rawSupplyShares,
241+
rawBorrowAssets,
242+
fee,
243+
ratesAtTarget[i],
244+
lastUpdate,
245+
now,
246+
)
247+
181248
let vaultSupplyAssets = 0n
182-
if (supplyShares > 0n && totalSupplyShares > 0n) {
183-
vaultSupplyAssets = (supplyShares * totalSupplyAssets) / totalSupplyShares
249+
if (supplyShares > 0n && accrued.totalSupplyShares > 0n) {
250+
vaultSupplyAssets = (supplyShares * accrued.totalSupplyAssets) / accrued.totalSupplyShares
184251
}
185252

186253
const [loanToken, collateralToken] = marketParams[i] as unknown as [string, string, string, string, bigint]
@@ -190,8 +257,8 @@ export async function fetchVaultMarketsViem(
190257
name: `${collateralSymbols[i]}/${loanSymbols[i]}`,
191258
loanToken,
192259
collateralToken,
193-
totalSupplyAssets,
194-
totalBorrowAssets,
260+
totalSupplyAssets: accrued.totalSupplyAssets,
261+
totalBorrowAssets: accrued.totalBorrowAssets,
195262
fee,
196263
vaultSupplyAssets,
197264
rateAtTarget: ratesAtTarget[i],

src/find-max.ts

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,6 @@ function buildWithdrawalImpact(
6969
}
7070
}
7171

72-
function makeFullDrainSnapshot(current: VaultApyDetailed): VaultApyDetailed {
73-
return {
74-
apy: 0,
75-
markets: current.markets.map((m) => ({
76-
marketId: m.marketId,
77-
name: m.name,
78-
supplyApy: 0,
79-
borrowApy: 0,
80-
utilization: 0,
81-
allocationPct: 0,
82-
vaultSupplyAssets: 0n,
83-
})),
84-
}
85-
}
86-
8772
function zeroDepositImpact(markets: MarketForApy[], includeMarkets: boolean): DepositImpactResult {
8873
if (markets.length === 0) {
8974
return { currentApy: 0, newApy: 0, impact: 0, impactBps: 0, markets: [] }
@@ -92,11 +77,7 @@ function zeroDepositImpact(markets: MarketForApy[], includeMarkets: boolean): De
9277
return buildDepositImpact(current, current, includeMarkets)
9378
}
9479

95-
function zeroWithdrawalImpact(
96-
markets: MarketForApy[],
97-
idleAssets: bigint,
98-
includeMarkets: boolean,
99-
): WithdrawalImpactResult {
80+
function zeroWithdrawalImpact(markets: MarketForApy[], includeMarkets: boolean): WithdrawalImpactResult {
10081
if (markets.length === 0) {
10182
return { currentApy: 0, newApy: 0, impact: 0, impactBps: 0, isPartial: false, withdrawableAmount: '0', markets: [] }
10283
}
@@ -207,6 +188,13 @@ export async function findMaxDepositAmount(
207188
}
208189
}
209190

191+
if (constraint && lo + step <= effectiveMax && (await isAcceptable(lo + step))) {
192+
console.warn(
193+
'[morpho-apy] findMaxDepositAmount: constraint may not be monotonic — ' +
194+
'amount above the binary search result still passes. Results may be inaccurate.',
195+
)
196+
}
197+
210198
const sim = simulateDeposit(markets, lo)
211199
const simulated = weightedVaultApyDetailed(markets, sim)
212200
return {
@@ -248,7 +236,7 @@ export async function findMaxWithdrawalAmount(
248236
maxPossibleAmount: 0n,
249237
isMaxAmount: maxAmount === 0n,
250238
isCapped: false,
251-
impact: zeroWithdrawalImpact(markets, idleAssets, incMkts),
239+
impact: zeroWithdrawalImpact(markets, incMkts),
252240
}
253241
}
254242

@@ -261,18 +249,16 @@ export async function findMaxWithdrawalAmount(
261249
maxPossibleAmount: maxPossible,
262250
isMaxAmount: false,
263251
isCapped: maxPossible === 0n,
264-
impact: zeroWithdrawalImpact(markets, idleAssets, incMkts),
252+
impact: zeroWithdrawalImpact(markets, incMkts),
265253
}
266254
}
267255

268256
const current = weightedVaultApyDetailed(markets)
269-
const totalVaultAssets = markets.reduce((acc, m) => acc + m.vaultSupplyAssets, 0n) + idleAssets
270257

271258
// Helper: is this amount acceptable (APY impact + custom constraint)?
272259
async function isAcceptable(amount: bigint): Promise<boolean> {
273260
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, amount, idleAssets)
274-
const isFullDrain = withdrawable >= totalVaultAssets
275-
const simulated = isFullDrain ? makeFullDrainSnapshot(current) : weightedVaultApyDetailed(markets, {}, sim)
261+
const simulated = weightedVaultApyDetailed(markets, {}, sim)
276262
const impact = Math.abs(simulated.apy - current.apy)
277263
if (impact > maxImpact) return false
278264
if (!constraint) return true
@@ -305,8 +291,7 @@ export async function findMaxWithdrawalAmount(
305291
// Fast path: full effectiveMax within threshold
306292
if (await isAcceptable(effectiveMax)) {
307293
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, effectiveMax, idleAssets)
308-
const simulated =
309-
withdrawable >= totalVaultAssets ? makeFullDrainSnapshot(current) : weightedVaultApyDetailed(markets, {}, sim)
294+
const simulated = weightedVaultApyDetailed(markets, {}, sim)
310295
return {
311296
amount: effectiveMax,
312297
maxPossibleAmount: maxPossible,
@@ -323,7 +308,7 @@ export async function findMaxWithdrawalAmount(
323308
maxPossibleAmount: maxPossible,
324309
isMaxAmount: false,
325310
isCapped: false,
326-
impact: zeroWithdrawalImpact(markets, idleAssets, incMkts),
311+
impact: zeroWithdrawalImpact(markets, incMkts),
327312
}
328313
}
329314

@@ -343,9 +328,15 @@ export async function findMaxWithdrawalAmount(
343328
}
344329
}
345330

331+
if (constraint && lo + step <= effectiveMax && (await isAcceptable(lo + step))) {
332+
console.warn(
333+
'[morpho-apy] findMaxWithdrawalAmount: constraint may not be monotonic — ' +
334+
'amount above the binary search result still passes. Results may be inaccurate.',
335+
)
336+
}
337+
346338
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, lo, idleAssets)
347-
const simulated =
348-
withdrawable >= totalVaultAssets ? makeFullDrainSnapshot(current) : weightedVaultApyDetailed(markets, {}, sim)
339+
const simulated = weightedVaultApyDetailed(markets, {}, sim)
349340
return {
350341
amount: lo,
351342
maxPossibleAmount: maxPossible,

src/math.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function estimateMarketApy(
7474
return { supplyApy: 0, borrowApy: 0 }
7575
}
7676

77-
let util = Math.min(totalBorrows / supply, 0.9999) // clamp to avoid division by zero
77+
let util = Math.min(totalBorrows / supply, 1.0)
7878
if (util < 0.0001) util = 0
7979

8080
const ratePerSec = rateAtTarget / WAD
@@ -117,13 +117,14 @@ export function weightedVaultApyDetailed(
117117
for (const m of markets) {
118118
const scale = Math.pow(10, m.decimals)
119119

120-
// Apply deposit simulation: increase totalSupplyAssets for target markets
121-
let simSupply = depositSim[m.marketId] ? m.totalSupplyAssets + depositSim[m.marketId] : m.totalSupplyAssets
120+
// Apply deposit simulation: increase both totalSupplyAssets and vaultSupplyAssets
121+
const dSim = depositSim[m.marketId] ?? 0n
122+
let simSupply = m.totalSupplyAssets + dSim
122123

123124
// Apply withdrawal simulation: decrease both totalSupplyAssets and vaultSupplyAssets
124125
const wSim = withdrawalSim[m.marketId] ?? 0n
125126
simSupply = simSupply - wSim
126-
const simVaultSupply = m.vaultSupplyAssets - wSim
127+
const simVaultSupply = m.vaultSupplyAssets + dSim - wSim
127128
warnIfPrecisionRisk(m.marketId, m.decimals, simSupply, simVaultSupply)
128129

129130
const supply = Number(simSupply) / scale
@@ -153,10 +154,7 @@ export function weightedVaultApyDetailed(
153154
}
154155

155156
if (totalWeight <= 0) {
156-
throw new Error(
157-
`[morpho-apy] Vault has zero supply position across all ${markets.length} market(s). ` +
158-
`Check vault address or wait for funds to be deployed.`,
159-
)
157+
return { apy: 0, markets: details }
160158
}
161159

162160
// Fill in allocationPct
@@ -285,15 +283,13 @@ export function mergeMarketDetails(current: MarketApyDetail[], simulated: Market
285283
allocationPct: c.allocationPct,
286284
vaultSupplyAssets: c.vaultSupplyAssets,
287285
},
288-
simulated: s
289-
? {
290-
supplyApy: s.supplyApy,
291-
borrowApy: s.borrowApy,
292-
utilization: s.utilization,
293-
allocationPct: s.allocationPct,
294-
vaultSupplyAssets: s.vaultSupplyAssets,
295-
}
296-
: { supplyApy: 0, borrowApy: 0, utilization: 0, allocationPct: 0, vaultSupplyAssets: 0n },
286+
simulated: {
287+
supplyApy: s.supplyApy,
288+
borrowApy: s.borrowApy,
289+
utilization: s.utilization,
290+
allocationPct: s.allocationPct,
291+
vaultSupplyAssets: s.vaultSupplyAssets,
292+
},
297293
}
298294
})
299295
}

src/withdrawal-impact.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,6 @@ import { fetchVaultApyViem } from './fetch.js'
55
import { mergeMarketDetails, simulateWithdrawal, weightedVaultApyDetailed } from './math.js'
66
import type { WithdrawalImpactResult } from './types.js'
77

8-
function makeFullDrainMarketDetails(current: ReturnType<typeof weightedVaultApyDetailed>) {
9-
return current.markets.map((m) => ({
10-
marketId: m.marketId,
11-
name: m.name,
12-
supplyApy: 0,
13-
borrowApy: 0,
14-
utilization: 0,
15-
allocationPct: 0,
16-
vaultSupplyAssets: 0n,
17-
}))
18-
}
19-
208
/**
219
* Compute the APY impact of a withdrawal from a MetaMorpho vault.
2210
* Makes live on-chain RPC calls via the provided viem PublicClient.
@@ -54,12 +42,7 @@ export async function computeWithdrawalImpact(
5442
const current = weightedVaultApyDetailed(markets)
5543
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, withdrawAmount, idleAssets)
5644

57-
// Edge case: full withdrawal drains all vault positions
58-
const totalVaultAssets = markets.reduce((acc, m) => acc + m.vaultSupplyAssets, 0n) + idleAssets
59-
const simulated =
60-
withdrawable >= totalVaultAssets
61-
? { apy: 0, markets: makeFullDrainMarketDetails(current) }
62-
: weightedVaultApyDetailed(markets, {}, sim)
45+
const simulated = weightedVaultApyDetailed(markets, {}, sim)
6346
const impact = simulated.apy - current.apy
6447

6548
return {

test/math.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ describe('weightedVaultApy', () => {
123123
expect(combined).toBeLessThan(apyA)
124124
})
125125

126-
it('throws when all markets have zero vault supply', () => {
126+
it('returns zero APY when all markets have zero vault supply', () => {
127127
const market = makeMarket({ vaultSupplyAssets: 0n })
128-
expect(() => weightedVaultApy([market])).toThrow(/zero supply position/)
128+
expect(weightedVaultApy([market])).toBe(0)
129129
})
130130

131131
it('skips markets with zero vault supply', () => {

0 commit comments

Comments
 (0)