Skip to content

Commit 1ffbd96

Browse files
committed
fix: bugs
1 parent b1835b5 commit 1ffbd96

7 files changed

Lines changed: 193 additions & 18 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,40 @@ When `includeMarkets: true`, impact results include a `markets` array of `Market
210210

211211
For `fetchVaultApy`, the `marketDetails` array contains `MarketApyDetail` (same fields as above, without the `current`/`simulated` nesting).
212212

213+
## Impact Sign Convention
214+
215+
Impact values are signed as:
216+
217+
```typescript
218+
impact = newApy - currentApy
219+
impactBps = round(impact * 10000)
220+
```
221+
222+
- negative impact means APY decreased
223+
- positive impact means APY increased
224+
225+
## Numerical Precision Notes
226+
227+
This library converts on-chain `bigint` balances to JavaScript `number` for APY math.
228+
229+
Because IEEE-754 doubles have 53 bits of integer precision, precision loss starts when raw base units exceed `2^53`:
230+
231+
- 18 decimals: `2^53 / 1e18 = 0.009007199254740993` tokens
232+
- 6 decimals: `2^53 / 1e6 = 9,007,199,254.740992` tokens
233+
234+
This does not automatically imply large APY error; it usually starts as tiny base-unit rounding.
235+
236+
Approximate 18-decimal token magnitudes where floating spacing reaches:
237+
238+
- `1e-12` tokens at ~`4.72e3` tokens
239+
- `1e-9` tokens at ~`4.84e6` tokens
240+
- `1e-6` tokens at ~`4.95e9` tokens
241+
- `1e-3` tokens at ~`5.07e12` tokens
242+
- `0.1` tokens at ~`6.49e14` tokens
243+
- `1` token at ~`5.19e15` tokens
244+
245+
At runtime, the math layer emits a one-time warning per market when estimated token spacing reaches `1e-6` tokens or higher.
246+
213247
## Custom Constraints
214248

215249
The `findMaxDeposit*` and `findMaxWithdrawal*` functions accept an optional `constraint` callback that adds custom criteria beyond APY impact. The binary search finds the largest amount satisfying **both** the APY impact threshold and the custom constraint.

docs/algorithm.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ This document describes the math behind vault APY computation, deposit impact si
1111
| WAD | 1e18 | Fixed-point scaling factor (18 decimals) |
1212
| SECONDS_PER_YEAR | 31,536,000 | 365 * 24 * 3600 (no leap year, matches Morpho convention) |
1313

14+
## 1.1 Numerical Precision
15+
16+
APY computation uses JavaScript `number` values after converting on-chain `bigint` balances by token decimals.
17+
18+
- First precision loss starts when raw base units exceed `2^53`.
19+
- For 18-decimal tokens, this is `0.009007199254740993` tokens.
20+
- For 6-decimal tokens, this is `9,007,199,254.740992` tokens.
21+
22+
In practice, rounding is usually tiny at first. For 18-decimal assets, approximate token magnitudes where floating spacing reaches:
23+
24+
- `1e-12` tokens at ~`4.72e3` tokens
25+
- `1e-9` tokens at ~`4.84e6` tokens
26+
- `1e-6` tokens at ~`4.95e9` tokens
27+
- `1e-3` tokens at ~`5.07e12` tokens
28+
29+
The implementation emits a one-time warning per market when estimated spacing reaches `1e-6` tokens or higher.
30+
1431
## 2. Single Market APY
1532

1633
### 2.1 Utilization
@@ -142,7 +159,8 @@ The increased supply lowers utilization, which typically lowers both borrow and
142159

143160
```
144161
newAPY = weightedVaultApy(markets, depositSim={})
145-
impactBps = round((currentAPY - newAPY) * 10000)
162+
impact = newAPY - currentAPY
163+
impactBps = round(impact * 10000)
146164
```
147165

148166
## 5. Withdrawal Simulation
@@ -396,8 +414,9 @@ function computeDepositImpact(markets, idleAssets, depositAmount):
396414
currentApy = weightedVaultApy(markets)
397415
sim = simulateDeposit(markets, depositAmount)
398416
newApy = weightedVaultApy(markets, depositSim=sim)
399-
impactBps = round((currentApy - newApy) * 10000)
400-
return { currentApy, newApy, impactBps }
417+
impact = newApy - currentApy
418+
impactBps = round(impact * 10000)
419+
return { currentApy, newApy, impact, impactBps }
401420
```
402421

403422
### 7.6 computeWithdrawalImpact
@@ -414,7 +433,8 @@ function computeWithdrawalImpact(markets, idleAssets, withdrawAmount):
414433
else:
415434
newApy = weightedVaultApy(markets, withdrawalSim=sim)
416435
417-
impactBps = round((currentApy - newApy) * 10000)
436+
impact = newApy - currentApy
437+
impactBps = round(impact * 10000)
418438
isPartial = remaining > 0
419-
return { currentApy, newApy, impactBps, isPartial, withdrawableAmount: withdrawable }
439+
return { currentApy, newApy, impact, impactBps, isPartial, withdrawableAmount: withdrawable }
420440
```

src/find-max.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
FindMaxConstraint,
99
FindMaxDepositResult,
1010
FindMaxWithdrawalResult,
11-
MarketApyDetail,
1211
MarketForApy,
1312
VaultApyDetailed,
1413
WithdrawalImpactResult,
@@ -70,6 +69,21 @@ function buildWithdrawalImpact(
7069
}
7170
}
7271

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+
7387
function zeroDepositImpact(markets: MarketForApy[], includeMarkets: boolean): DepositImpactResult {
7488
if (markets.length === 0) {
7589
return { currentApy: 0, newApy: 0, impact: 0, impactBps: 0, markets: [] }
@@ -258,9 +272,7 @@ export async function findMaxWithdrawalAmount(
258272
async function isAcceptable(amount: bigint): Promise<boolean> {
259273
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, amount, idleAssets)
260274
const isFullDrain = withdrawable >= totalVaultAssets
261-
const simulated = isFullDrain
262-
? { apy: 0, markets: [] as MarketApyDetail[] }
263-
: weightedVaultApyDetailed(markets, {}, sim)
275+
const simulated = isFullDrain ? makeFullDrainSnapshot(current) : weightedVaultApyDetailed(markets, {}, sim)
264276
const impact = Math.abs(simulated.apy - current.apy)
265277
if (impact > maxImpact) return false
266278
if (!constraint) return true
@@ -294,9 +306,7 @@ export async function findMaxWithdrawalAmount(
294306
if (await isAcceptable(effectiveMax)) {
295307
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, effectiveMax, idleAssets)
296308
const simulated =
297-
withdrawable >= totalVaultAssets
298-
? { apy: 0, markets: [] as MarketApyDetail[] }
299-
: weightedVaultApyDetailed(markets, {}, sim)
309+
withdrawable >= totalVaultAssets ? makeFullDrainSnapshot(current) : weightedVaultApyDetailed(markets, {}, sim)
300310
return {
301311
amount: effectiveMax,
302312
maxPossibleAmount: maxPossible,
@@ -335,9 +345,7 @@ export async function findMaxWithdrawalAmount(
335345

336346
const { sim, withdrawable, remaining } = simulateWithdrawal(markets, lo, idleAssets)
337347
const simulated =
338-
withdrawable >= totalVaultAssets
339-
? { apy: 0, markets: [] as MarketApyDetail[] }
340-
: weightedVaultApyDetailed(markets, {}, sim)
348+
withdrawable >= totalVaultAssets ? makeFullDrainSnapshot(current) : weightedVaultApyDetailed(markets, {}, sim)
341349
return {
342350
amount: lo,
343351
maxPossibleAmount: maxPossible,

src/math.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ const TARGET_UTIL = 0.9
1010
const STEEPNESS = 4
1111
const WAD = 1e18
1212
const SECONDS_PER_YEAR = 365 * 24 * 3600 // 365-day year, no leap year — matches Morpho convention
13+
const PRECISION_WARNING_TOKEN_SPACING = 1e-6
14+
const PRECISION_WARNED_MARKETS = new Set<string>()
15+
16+
function estimateTokenSpacing(rawAmount: bigint, decimals: number): number {
17+
const abs = rawAmount < 0n ? -rawAmount : rawAmount
18+
if (abs === 0n) return 0
19+
const bitLength = abs.toString(2).length
20+
if (bitLength <= 53) return 0
21+
const spacingRaw = 2 ** (bitLength - 53)
22+
return spacingRaw / Math.pow(10, decimals)
23+
}
24+
25+
function warnIfPrecisionRisk(marketId: string, decimals: number, simSupply: bigint, simVaultSupply: bigint): void {
26+
if (PRECISION_WARNED_MARKETS.has(marketId)) return
27+
const supplySpacing = estimateTokenSpacing(simSupply, decimals)
28+
const vaultSpacing = estimateTokenSpacing(simVaultSupply, decimals)
29+
const spacing = Math.max(supplySpacing, vaultSpacing)
30+
if (spacing < PRECISION_WARNING_TOKEN_SPACING) return
31+
32+
PRECISION_WARNED_MARKETS.add(marketId)
33+
console.warn(
34+
`[morpho-apy] Potential floating-point precision loss for market ${marketId}: ` +
35+
`number spacing is ~${spacing.toExponential(2)} tokens at current magnitudes.`,
36+
)
37+
}
1338

1439
/** Normalized distance from target utilization. Range: [-1, +1]. */
1540
function _error(util: number): number {
@@ -99,6 +124,7 @@ export function weightedVaultApyDetailed(
99124
const wSim = withdrawalSim[m.marketId] ?? 0n
100125
simSupply = simSupply - wSim
101126
const simVaultSupply = m.vaultSupplyAssets - wSim
127+
warnIfPrecisionRisk(m.marketId, m.decimals, simSupply, simVaultSupply)
102128

103129
const supply = Number(simSupply) / scale
104130
const borrows = Number(m.totalBorrowAssets) / scale
@@ -228,8 +254,27 @@ export function simulateWithdrawal(
228254
* array of MarketImpactDetail with nested `current` and `simulated` snapshots.
229255
*/
230256
export function mergeMarketDetails(current: MarketApyDetail[], simulated: MarketApyDetail[]): MarketImpactDetail[] {
231-
return current.map((c, i) => {
232-
const s = simulated[i]
257+
const currentIds = new Set<string>()
258+
for (const c of current) {
259+
if (currentIds.has(c.marketId)) {
260+
throw new Error(`[morpho-apy] Duplicate current marketId in mergeMarketDetails: ${c.marketId}`)
261+
}
262+
currentIds.add(c.marketId)
263+
}
264+
265+
const simulatedById = new Map<string, MarketApyDetail>()
266+
for (const s of simulated) {
267+
if (simulatedById.has(s.marketId)) {
268+
throw new Error(`[morpho-apy] Duplicate simulated marketId in mergeMarketDetails: ${s.marketId}`)
269+
}
270+
simulatedById.set(s.marketId, s)
271+
}
272+
273+
return current.map((c) => {
274+
const s = simulatedById.get(c.marketId)
275+
if (!s) {
276+
throw new Error(`[morpho-apy] Missing simulated marketId in mergeMarketDetails: ${c.marketId}`)
277+
}
233278
return {
234279
marketId: c.marketId,
235280
name: c.name,

src/withdrawal-impact.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ 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+
820
/**
921
* Compute the APY impact of a withdrawal from a MetaMorpho vault.
1022
* Makes live on-chain RPC calls via the provided viem PublicClient.
@@ -45,7 +57,9 @@ export async function computeWithdrawalImpact(
4557
// Edge case: full withdrawal drains all vault positions
4658
const totalVaultAssets = markets.reduce((acc, m) => acc + m.vaultSupplyAssets, 0n) + idleAssets
4759
const simulated =
48-
withdrawable >= totalVaultAssets ? { apy: 0, markets: [] } : weightedVaultApyDetailed(markets, {}, sim)
60+
withdrawable >= totalVaultAssets
61+
? { apy: 0, markets: makeFullDrainMarketDetails(current) }
62+
: weightedVaultApyDetailed(markets, {}, sim)
4963
const impact = simulated.apy - current.apy
5064

5165
return {

test/find-max.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,22 @@ describe('findMaxWithdrawalAmount', async () => {
378378
expect(result.impact.newApy).toBe(0)
379379
})
380380

381+
it('full drain still returns includeMarkets details', async () => {
382+
const market = makeMarket({
383+
totalSupplyAssets: 500n * TOKEN,
384+
totalBorrowAssets: 0n,
385+
vaultSupplyAssets: 500n * TOKEN,
386+
withdrawQueueIndex: 0,
387+
})
388+
const idle = 100n * TOKEN
389+
const result = await findMaxWithdrawalAmount([market], idle, 600n * TOKEN, 10000, { includeMarkets: true })
390+
391+
expect(result.amount).toBe(600n * TOKEN)
392+
expect(result.impact.newApy).toBe(0)
393+
expect(result.impact.markets).toHaveLength(1)
394+
expect(result.impact.markets[0].simulated.vaultSupplyAssets).toBe(0n)
395+
})
396+
381397
it('returns 0 when market is 100% utilized and no idle', async () => {
382398
const market = makeMarket({
383399
totalSupplyAssets: 1000n * TOKEN,

test/math.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
22

33
import {
44
estimateMarketApy,
5+
mergeMarketDetails,
56
simulateDeposit,
67
simulateWithdrawal,
78
weightedVaultApy,
@@ -358,3 +359,40 @@ describe('weightedVaultApyDetailed', () => {
358359
expect(detailed.markets[0].vaultSupplyAssets).toBe(400n * 10n ** 18n)
359360
})
360361
})
362+
363+
describe('mergeMarketDetails', () => {
364+
it('merges by marketId even when simulated order differs', () => {
365+
const current = weightedVaultApyDetailed([
366+
makeMarket({ marketId: '0xa' }),
367+
makeMarket({ marketId: '0xb', totalBorrowAssets: 700n * 10n ** 18n }),
368+
]).markets
369+
const simulated = weightedVaultApyDetailed([
370+
makeMarket({ marketId: '0xa', totalSupplyAssets: 1100n * 10n ** 18n }),
371+
makeMarket({ marketId: '0xb', totalSupplyAssets: 1200n * 10n ** 18n }),
372+
]).markets
373+
374+
const reordered = [simulated[1], simulated[0]]
375+
const merged = mergeMarketDetails(current, reordered)
376+
377+
expect(merged).toHaveLength(2)
378+
expect(merged[0].marketId).toBe('0xa')
379+
expect(merged[0].simulated.vaultSupplyAssets).toBe(simulated[0].vaultSupplyAssets)
380+
expect(merged[1].marketId).toBe('0xb')
381+
expect(merged[1].simulated.vaultSupplyAssets).toBe(simulated[1].vaultSupplyAssets)
382+
})
383+
384+
it('throws on duplicate simulated market IDs', () => {
385+
const current = weightedVaultApyDetailed([makeMarket({ marketId: '0xa' })]).markets
386+
const simulated = weightedVaultApyDetailed([makeMarket({ marketId: '0xa' })]).markets
387+
const duplicated = [simulated[0], simulated[0]]
388+
389+
expect(() => mergeMarketDetails(current, duplicated)).toThrow(/Duplicate simulated marketId/)
390+
})
391+
392+
it('throws when a current market is missing from simulated details', () => {
393+
const current = weightedVaultApyDetailed([makeMarket({ marketId: '0xa' }), makeMarket({ marketId: '0xb' })]).markets
394+
const simulated = weightedVaultApyDetailed([makeMarket({ marketId: '0xa' })]).markets
395+
396+
expect(() => mergeMarketDetails(current, simulated)).toThrow(/Missing simulated marketId/)
397+
})
398+
})

0 commit comments

Comments
 (0)