@moonwell-fi/moonwell-sdk 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/_cjs/actions/core/getUserBalances.js +63 -32
  3. package/_cjs/actions/core/getUserBalances.js.map +1 -1
  4. package/_cjs/actions/core/markets/common.js +1 -7
  5. package/_cjs/actions/core/markets/common.js.map +1 -1
  6. package/_cjs/actions/governance/getStakingInfo.js +1 -7
  7. package/_cjs/actions/governance/getStakingInfo.js.map +1 -1
  8. package/_cjs/actions/governance/getUserStakingInfo.js +1 -7
  9. package/_cjs/actions/governance/getUserStakingInfo.js.map +1 -1
  10. package/_cjs/actions/governance/getWellPrice.js +51 -1
  11. package/_cjs/actions/governance/getWellPrice.js.map +1 -1
  12. package/_cjs/actions/governance/proposals/common.js +34 -10
  13. package/_cjs/actions/governance/proposals/common.js.map +1 -1
  14. package/_cjs/actions/governance/proposals/getProposals.js +5 -4
  15. package/_cjs/actions/governance/proposals/getProposals.js.map +1 -1
  16. package/_cjs/actions/morpho/markets/common.js +29 -2
  17. package/_cjs/actions/morpho/markets/common.js.map +1 -1
  18. package/_cjs/actions/morpho/user-rewards/common.js +1 -7
  19. package/_cjs/actions/morpho/user-rewards/common.js.map +1 -1
  20. package/_cjs/actions/morpho/vaults/common.js +104 -87
  21. package/_cjs/actions/morpho/vaults/common.js.map +1 -1
  22. package/_cjs/actions/morpho/vaults/lunarIndexerTransform.js +31 -3
  23. package/_cjs/actions/morpho/vaults/lunarIndexerTransform.js.map +1 -1
  24. package/_cjs/environments/types/config.js.map +1 -1
  25. package/_cjs/errors/version.js +1 -1
  26. package/_esm/actions/core/getUserBalances.js +72 -35
  27. package/_esm/actions/core/getUserBalances.js.map +1 -1
  28. package/_esm/actions/core/markets/common.js +2 -8
  29. package/_esm/actions/core/markets/common.js.map +1 -1
  30. package/_esm/actions/governance/getStakingInfo.js +2 -8
  31. package/_esm/actions/governance/getStakingInfo.js.map +1 -1
  32. package/_esm/actions/governance/getUserStakingInfo.js +2 -8
  33. package/_esm/actions/governance/getUserStakingInfo.js.map +1 -1
  34. package/_esm/actions/governance/getWellPrice.js +67 -7
  35. package/_esm/actions/governance/getWellPrice.js.map +1 -1
  36. package/_esm/actions/governance/proposals/common.js +37 -10
  37. package/_esm/actions/governance/proposals/common.js.map +1 -1
  38. package/_esm/actions/governance/proposals/getProposals.js +7 -5
  39. package/_esm/actions/governance/proposals/getProposals.js.map +1 -1
  40. package/_esm/actions/morpho/markets/common.js +34 -2
  41. package/_esm/actions/morpho/markets/common.js.map +1 -1
  42. package/_esm/actions/morpho/user-rewards/common.js +2 -8
  43. package/_esm/actions/morpho/user-rewards/common.js.map +1 -1
  44. package/_esm/actions/morpho/vaults/common.js +110 -91
  45. package/_esm/actions/morpho/vaults/common.js.map +1 -1
  46. package/_esm/actions/morpho/vaults/lunarIndexerTransform.js +36 -4
  47. package/_esm/actions/morpho/vaults/lunarIndexerTransform.js.map +1 -1
  48. package/_esm/environments/types/config.js.map +1 -1
  49. package/_esm/errors/version.js +1 -1
  50. package/_types/actions/core/getUserBalances.d.ts.map +1 -1
  51. package/_types/actions/core/markets/common.d.ts.map +1 -1
  52. package/_types/actions/governance/getStakingInfo.d.ts.map +1 -1
  53. package/_types/actions/governance/getUserStakingInfo.d.ts.map +1 -1
  54. package/_types/actions/governance/getWellPrice.d.ts +9 -1
  55. package/_types/actions/governance/getWellPrice.d.ts.map +1 -1
  56. package/_types/actions/governance/proposals/common.d.ts.map +1 -1
  57. package/_types/actions/governance/proposals/getProposals.d.ts +10 -1
  58. package/_types/actions/governance/proposals/getProposals.d.ts.map +1 -1
  59. package/_types/actions/morpho/markets/common.d.ts.map +1 -1
  60. package/_types/actions/morpho/user-rewards/common.d.ts.map +1 -1
  61. package/_types/actions/morpho/vaults/common.d.ts.map +1 -1
  62. package/_types/actions/morpho/vaults/lunarIndexerTransform.d.ts.map +1 -1
  63. package/_types/environments/types/config.d.ts +11 -1
  64. package/_types/environments/types/config.d.ts.map +1 -1
  65. package/_types/errors/version.d.ts +1 -1
  66. package/actions/core/getUserBalances.ts +98 -51
  67. package/actions/core/markets/common.ts +5 -8
  68. package/actions/governance/getStakingInfo.ts +2 -9
  69. package/actions/governance/getUserStakingInfo.ts +2 -8
  70. package/actions/governance/getWellPrice.ts +99 -7
  71. package/actions/governance/proposals/common.ts +44 -16
  72. package/actions/governance/proposals/getProposals.ts +22 -5
  73. package/actions/morpho/markets/common.ts +37 -2
  74. package/actions/morpho/user-rewards/common.ts +2 -8
  75. package/actions/morpho/vaults/common.ts +161 -127
  76. package/actions/morpho/vaults/lunarIndexerTransform.ts +41 -6
  77. package/environments/types/config.ts +11 -1
  78. package/errors/version.ts +1 -1
  79. package/package.json +1 -1
@@ -447,6 +447,29 @@ export const getProposalsOnChainData = async (
447
447
  ? governanceEnvironment
448
448
  : getEnvironmentByChainId(chainId);
449
449
 
450
+ // viem batches these reads into one multicall, so a single failed request
451
+ // rejects every read in it. Collect failures per chain and report them once
452
+ // after the batch instead of once per read (Sentry MOONWELL-FRONTEND-1AX).
453
+ const readFailures = new Map<
454
+ number,
455
+ { error: unknown; failed: number; total: number; proposalIds: Set<string> }
456
+ >();
457
+ const recordRead = (chainId: number, proposalId: number, error?: unknown) => {
458
+ const entry = readFailures.get(chainId) ?? {
459
+ error: undefined,
460
+ failed: 0,
461
+ total: 0,
462
+ proposalIds: new Set<string>(),
463
+ };
464
+ entry.total++;
465
+ if (error !== undefined) {
466
+ entry.error ??= error;
467
+ entry.failed++;
468
+ entry.proposalIds.add(String(proposalId));
469
+ }
470
+ readFailures.set(chainId, entry);
471
+ };
472
+
450
473
  const onChainDataList = await Promise.all(
451
474
  apiProposals.map(async (p) => {
452
475
  const isLocal = p.chainId === governanceEnvironment.chainId;
@@ -501,29 +524,17 @@ export const getProposalsOnChainData = async (
501
524
  ]);
502
525
  if (stateResult.status === "fulfilled") {
503
526
  state = Number(stateResult.value);
527
+ recordRead(p.chainId, p.proposalId);
504
528
  } else {
505
529
  stateReadFailed = true;
506
- console.warn(
507
- `Failed to fetch state for proposal ${p.proposalId} (chainId=${p.chainId}):`,
508
- stateResult.reason,
509
- );
510
- governanceEnvironment.onError?.(stateResult.reason, {
511
- source: "governance-proposals",
512
- chainId: p.chainId,
513
- });
530
+ recordRead(p.chainId, p.proposalId, stateResult.reason);
514
531
  }
515
532
  if (proposalsResult.status === "fulfilled") {
516
533
  proposalData = proposalsResult.value ?? null;
534
+ recordRead(p.chainId, p.proposalId);
517
535
  } else {
518
536
  proposalsReadFailed = true;
519
- console.warn(
520
- `Failed to fetch proposalData for proposal ${p.proposalId} (chainId=${p.chainId}):`,
521
- proposalsResult.reason,
522
- );
523
- governanceEnvironment.onError?.(proposalsResult.reason, {
524
- source: "governance-proposals",
525
- chainId: p.chainId,
526
- });
537
+ recordRead(p.chainId, p.proposalId, proposalsResult.reason);
527
538
  }
528
539
  }
529
540
 
@@ -609,6 +620,23 @@ export const getProposalsOnChainData = async (
609
620
  }),
610
621
  );
611
622
 
623
+ for (const [chainId, entry] of readFailures) {
624
+ if (entry.failed === 0) continue;
625
+ const proposalIds = [...entry.proposalIds];
626
+ console.warn(
627
+ `Failed ${entry.failed}/${entry.total} governor reads (chainId=${chainId}, proposals ${proposalIds.join(", ")}):`,
628
+ entry.error,
629
+ );
630
+ governanceEnvironment.onError?.(entry.error, {
631
+ source: "governance-proposals",
632
+ chainId,
633
+ operation: "proposal-onchain-read",
634
+ failedCount: entry.failed,
635
+ totalCount: entry.total,
636
+ items: proposalIds,
637
+ });
638
+ }
639
+
612
640
  return onChainDataList;
613
641
  };
614
642
 
@@ -20,7 +20,16 @@ import {
20
20
  export type GetProposalsParameters<
21
21
  environments,
22
22
  network extends Chain | undefined,
23
- > = OptionalNetworkParameterType<environments, network>;
23
+ > = OptionalNetworkParameterType<environments, network> & {
24
+ /**
25
+ * Governance chains to fetch proposals from: 1 (Ethereum, active),
26
+ * 1284 (Moonbeam archive) and/or 1285 (Moonriver archive). Defaults to all
27
+ * of them. The archives hold almost all of the payload (~6.6MB of ~7MB), so
28
+ * views that only show recent proposals can pass `[1]` and load the archives
29
+ * separately when needed. Unsupported chainIds are ignored.
30
+ */
31
+ proposalChainIds?: readonly number[];
32
+ };
24
33
 
25
34
  export type GetProposalsReturnType = Promise<Proposal[]>;
26
35
 
@@ -42,7 +51,10 @@ export async function getProposals<
42
51
  return [];
43
52
  }
44
53
 
45
- const proposals = await fetchGovernorProposals(governanceEnvironment);
54
+ const proposals = await fetchGovernorProposals(
55
+ governanceEnvironment,
56
+ args?.proposalChainIds,
57
+ );
46
58
  // Newer multigov-ethereum proposals (chainId=1) and historical Moonbeam
47
59
  // proposals (chainId=1284) restart their proposalId counters from 1, so IDs
48
60
  // may collide across chains. Sort by proposalId desc with chainId as a
@@ -58,7 +70,8 @@ export async function getProposals<
58
70
  }
59
71
 
60
72
  /**
61
- * Fetch every proposal the Governor API serves, across all governance chains.
73
+ * Fetch every proposal the Governor API serves, across all governance chains
74
+ * (or only `proposalChainIds`, when given).
62
75
  *
63
76
  * One indexer DO serves them all:
64
77
  * - chainId=1 (Ethereum) — the active multigov contract
@@ -76,16 +89,20 @@ export async function getProposals<
76
89
  */
77
90
  async function fetchGovernorProposals(
78
91
  governanceEnvironment: Environment,
92
+ proposalChainIds?: readonly number[],
79
93
  ): Promise<Proposal[]> {
94
+ const chainIds = SUPPORTED_GOVERNOR_CHAIN_IDS.filter(
95
+ (chainId) => !proposalChainIds || proposalChainIds.includes(chainId),
96
+ );
80
97
  const results = await Promise.allSettled(
81
- SUPPORTED_GOVERNOR_CHAIN_IDS.map((chainId) =>
98
+ chainIds.map((chainId) =>
82
99
  fetchAllProposals(governanceEnvironment, { chainId }),
83
100
  ),
84
101
  );
85
102
 
86
103
  const apiProposals: ApiProposal[] = [];
87
104
  results.forEach((result, index) => {
88
- const chainId = SUPPORTED_GOVERNOR_CHAIN_IDS[index];
105
+ const chainId = chainIds[index];
89
106
  if (result.status === "fulfilled") {
90
107
  apiProposals.push(...result.value);
91
108
  } else if (chainId !== undefined) {
@@ -76,6 +76,21 @@ function validateLoanDecimals(value: unknown): number {
76
76
  return value;
77
77
  }
78
78
 
79
+ /** Summarizes an unexpected top-level body so the error says what arrived. */
80
+ function describeSharedLiquidityBody(data: unknown): string {
81
+ if (data === null || data === undefined) return `received ${data}`;
82
+ if (typeof data === "string") {
83
+ return `received string of ${data.length} chars: ${JSON.stringify(data.slice(0, 100))}`;
84
+ }
85
+ if (typeof data !== "object") return `received ${typeof data}`;
86
+ if (Array.isArray(data)) return `received array of ${data.length}`;
87
+ const body = data as Record<string, unknown>;
88
+ const kind = (value: unknown): string =>
89
+ Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
90
+ const keys = Object.keys(body).slice(0, 10).join(", ");
91
+ return `received object with keys [${keys}]; vaults: ${kind(body.vaults)}, markets: ${kind(body.markets)}`;
92
+ }
93
+
79
94
  function validatedMarketLiquidity(
80
95
  data: LunarSharedLiquidityResponse,
81
96
  ): Map<string, number> {
@@ -86,7 +101,9 @@ function validatedMarketLiquidity(
86
101
  typeof data.markets !== "object" ||
87
102
  Array.isArray(data.markets)
88
103
  ) {
89
- throw new TypeError("Invalid shared-liquidity response");
104
+ throw new TypeError(
105
+ `Invalid shared-liquidity response (${describeSharedLiquidityBody(data)})`,
106
+ );
90
107
  }
91
108
  for (const vault of data.vaults) {
92
109
  parseLiquidityQuantity(vault.fee, "vault.fee", true);
@@ -158,13 +175,31 @@ export async function fetchSharedLiquidityFromLunar(
158
175
  const abort = (): void => controller.abort();
159
176
  if (options.signal?.aborted) abort();
160
177
  options.signal?.addEventListener("abort", abort, { once: true });
161
- const timer = setTimeout(abort, timeoutMs);
178
+ let timedOut = false;
179
+ const timer = setTimeout(() => {
180
+ timedOut = true;
181
+ abort();
182
+ }, timeoutMs);
162
183
  try {
163
184
  const response = await getWithRetry<LunarSharedLiquidityResponse>(
164
185
  `${lunarIndexerUrl}/api/v1/isolated/shared-liquidity/${chainId}`,
165
186
  { timeout: timeoutMs, signal: controller.signal },
166
187
  );
167
188
  return response.data;
189
+ } catch (error) {
190
+ // Our own deadline aborts the request, which axios reports as a bare
191
+ // `CanceledError: canceled` (Sentry MOONWELL-FRONTEND-1C7 & co). That is a
192
+ // timeout, not a cancellation: surface it as one. A caller's own abort
193
+ // still propagates as the CanceledError it is.
194
+ if (timedOut && !options.signal?.aborted) {
195
+ const timeoutError = new Error(
196
+ `Shared-liquidity request timed out after ${timeoutMs}ms (chainId=${chainId})`,
197
+ { cause: error },
198
+ );
199
+ timeoutError.name = "TimeoutError";
200
+ throw timeoutError;
201
+ }
202
+ throw error;
168
203
  } finally {
169
204
  clearTimeout(timer);
170
205
  options.signal?.removeEventListener("abort", abort);
@@ -13,7 +13,7 @@ import {
13
13
  } from "../../../environments/utils/index.js";
14
14
  import type { MorphoUserReward } from "../../../types/morphoUserReward.js";
15
15
  import type { MorphoUserStakingReward } from "../../../types/morphoUserStakingReward.js";
16
- import { getGovernanceTokenPriceFor } from "../../governance/getWellPrice.js";
16
+ import { getGovernanceTokenPriceOrZero } from "../../governance/getWellPrice.js";
17
17
 
18
18
  /**
19
19
  * Error thrown for any failure communicating with the Merkl API: non-ok HTTP
@@ -166,13 +166,7 @@ export async function getUserMorphoStakingRewardsData(params: {
166
166
  await Promise.all([
167
167
  viewsContract?.read.getAllMarketsInfo(),
168
168
  homeViewsContract?.read.getNativeTokenPrice(),
169
- getGovernanceTokenPriceFor(params.environment).catch((err) => {
170
- params.environment.onError?.(err, {
171
- source: "governance-token-price",
172
- chainId: params.environment.chainId,
173
- });
174
- return 0n;
175
- }),
169
+ getGovernanceTokenPriceOrZero(params.environment),
176
170
  ]);
177
171
 
178
172
  const governanceTokenPrice = new Amount(governanceTokenPriceRaw, 18);
@@ -19,7 +19,7 @@ import type {
19
19
  MorphoVault,
20
20
  MorphoVaultMarket,
21
21
  } from "../../../types/morphoVault.js";
22
- import { getGovernanceTokenPriceFor } from "../../governance/getWellPrice.js";
22
+ import { getGovernanceTokenPriceOrZero } from "../../governance/getWellPrice.js";
23
23
  import { getGraphQL, getVaultV2Apy } from "../utils/graphql.js";
24
24
  import {
25
25
  SECONDS_PER_YEAR,
@@ -300,144 +300,184 @@ async function getMorphoVaultsDataFromIndexer(params: {
300
300
 
301
301
  // Add staking rewards if includeRewards is true
302
302
  if (params.includeRewards) {
303
+ const vaultsByEnvironment = new Map<
304
+ Environment,
305
+ { vault: MorphoVault; multiReward: Address }[]
306
+ >();
303
307
  for (const vault of allVaults) {
304
308
  const environment = environments.find(
305
309
  (env) => env.chainId === vault.chainId,
306
310
  );
307
311
  if (!environment) continue;
308
312
 
309
- const vaultConfig = environment.config.vaults[vault.vaultKey];
310
- if (!vaultConfig?.multiReward) continue;
313
+ const multiReward =
314
+ environment.config.vaults[vault.vaultKey]?.multiReward;
315
+ if (!multiReward) continue;
311
316
 
312
- try {
313
- // Fetch market prices from the views contract to calculate rewards
314
- const homeEnvironment =
315
- (Object.values(publicEnvironments) as Environment[]).find((e) =>
316
- e.custom?.governance?.chainIds?.includes(environment.chainId),
317
- ) || environment;
318
-
319
- const viewsContract = environment.contracts.views;
320
- const homeViewsContract = homeEnvironment.contracts.views;
321
-
322
- const data = await Promise.all([
323
- viewsContract?.read.getAllMarketsInfo(),
324
- homeViewsContract?.read.getNativeTokenPrice(),
325
- getGovernanceTokenPriceFor(environment).catch((err) => {
326
- environment.onError?.(err, {
327
- source: "governance-token-price",
328
- chainId: environment.chainId,
329
- });
330
- return 0n;
331
- }),
332
- ]);
317
+ const group = vaultsByEnvironment.get(environment) ?? [];
318
+ group.push({ vault, multiReward });
319
+ vaultsByEnvironment.set(environment, group);
320
+ }
333
321
 
334
- const [allMarkets, nativeTokenPriceRaw, governanceTokenPriceRaw] = data;
322
+ // Price reads are per environment, not per vault: read them once per
323
+ // environment (in parallel across environments) and report failures once.
324
+ await Promise.all(
325
+ [...vaultsByEnvironment].map(([environment, group]) =>
326
+ addStakingRewardsForEnvironment(environment, group),
327
+ ),
328
+ );
329
+ }
335
330
 
336
- const governanceTokenPrice = new Amount(governanceTokenPriceRaw, 18);
337
- const nativeTokenPrice = new Amount(nativeTokenPriceRaw ?? 0n, 18);
331
+ return [...allVaults, ...fallbackVaults];
332
+ }
338
333
 
339
- let tokenPrices =
340
- allMarkets
341
- ?.map((marketInfo) => {
342
- const marketFound = findMarketByAddress(
343
- environment,
344
- marketInfo.market,
345
- );
346
- if (marketFound) {
347
- return {
348
- token: marketFound.underlyingToken,
349
- tokenPrice: new Amount(
350
- marketInfo.underlyingPrice,
351
- 36 - marketFound.underlyingToken.decimals,
352
- ),
353
- };
354
- } else {
355
- return;
356
- }
357
- })
358
- .filter((token) => !!token) || [];
359
-
360
- // Add governance token to token prices
361
- if (environment.custom?.governance?.token) {
362
- tokenPrices = [
363
- ...tokenPrices,
364
- {
365
- token:
366
- environment.config.tokens[environment.custom.governance.token]!,
367
- tokenPrice: governanceTokenPrice,
368
- },
369
- ];
370
- }
334
+ async function getStakingRewardTokenPrices(
335
+ environment: Environment,
336
+ ): Promise<{ token: TokenConfig; tokenPrice: Amount }[]> {
337
+ // Fetch market prices from the views contract to calculate rewards
338
+ const homeEnvironment =
339
+ (Object.values(publicEnvironments) as Environment[]).find((e) =>
340
+ e.custom?.governance?.chainIds?.includes(environment.chainId),
341
+ ) || environment;
342
+
343
+ const [allMarkets, nativeTokenPriceRaw, governanceTokenPriceRaw] =
344
+ await Promise.all([
345
+ environment.contracts.views?.read.getAllMarketsInfo(),
346
+ homeEnvironment.contracts.views?.read.getNativeTokenPrice(),
347
+ getGovernanceTokenPriceOrZero(environment),
348
+ ]);
349
+
350
+ const tokenPrices: { token: TokenConfig; tokenPrice: Amount }[] = [];
351
+ for (const marketInfo of allMarkets ?? []) {
352
+ const marketFound = findMarketByAddress(environment, marketInfo.market);
353
+ if (marketFound) {
354
+ tokenPrices.push({
355
+ token: marketFound.underlyingToken,
356
+ tokenPrice: new Amount(
357
+ marketInfo.underlyingPrice,
358
+ 36 - marketFound.underlyingToken.decimals,
359
+ ),
360
+ });
361
+ }
362
+ }
371
363
 
372
- // Add native token to token prices
373
- tokenPrices = [
374
- ...tokenPrices,
375
- {
376
- token: findTokenByAddress(environment, zeroAddress)!,
377
- tokenPrice: nativeTokenPrice,
378
- },
379
- ];
364
+ // Add governance token to token prices
365
+ const governanceToken = environment.custom?.governance?.token
366
+ ? environment.config.tokens[environment.custom.governance.token]
367
+ : undefined;
368
+ if (governanceToken) {
369
+ tokenPrices.push({
370
+ token: governanceToken,
371
+ tokenPrice: new Amount(governanceTokenPriceRaw, 18),
372
+ });
373
+ }
380
374
 
381
- const rewards = await getRewardsData(
382
- environment,
383
- vaultConfig.multiReward,
384
- );
375
+ // Add native token to token prices
376
+ const nativeToken = findTokenByAddress(environment, zeroAddress);
377
+ if (nativeToken) {
378
+ tokenPrices.push({
379
+ token: nativeToken,
380
+ tokenPrice: new Amount(nativeTokenPriceRaw ?? 0n, 18),
381
+ });
382
+ }
385
383
 
386
- const distributorTotalSupply = await getTotalSupplyData(
387
- environment,
388
- vaultConfig.multiReward,
389
- );
384
+ return tokenPrices;
385
+ }
390
386
 
391
- rewards
392
- .filter(
393
- (reward) =>
394
- reward?.periodFinish &&
395
- dayjs.utc().isBefore(dayjs.unix(Number(reward.periodFinish))),
396
- )
397
- .forEach((reward) => {
398
- const token = Object.values(environment.config.tokens).find(
399
- (token) => token.address === reward?.token,
400
- );
401
- if (!token || !reward?.rewardRate) return;
387
+ async function addStakingRewardsForEnvironment(
388
+ environment: Environment,
389
+ group: { vault: MorphoVault; multiReward: Address }[],
390
+ ): Promise<void> {
391
+ const vaultKeys = group.map(({ vault }) => vault.vaultKey);
402
392
 
403
- const market = tokenPrices.find(
404
- (m) => m?.token.address === reward.token,
405
- );
393
+ let tokenPrices: { token: TokenConfig; tokenPrice: Amount }[];
394
+ try {
395
+ tokenPrices = await getStakingRewardTokenPrices(environment);
396
+ } catch (error) {
397
+ console.warn(
398
+ `Failed to fetch staking reward prices for chain ${environment.chainId}:`,
399
+ error,
400
+ );
401
+ environment.onError?.(error, {
402
+ source: "morpho-vault-staking-rewards",
403
+ chainId: environment.chainId,
404
+ operation: "staking-reward-prices",
405
+ failedCount: group.length,
406
+ totalCount: group.length,
407
+ items: vaultKeys,
408
+ });
409
+ return;
410
+ }
406
411
 
407
- const rewardPriceUsd = market?.tokenPrice.value ?? 0;
408
-
409
- const rewardsPerYear =
410
- new Amount(reward.rewardRate, market?.token.decimals ?? 18)
411
- .value *
412
- SECONDS_PER_YEAR *
413
- rewardPriceUsd;
414
-
415
- vault.stakingRewards.push({
416
- apr:
417
- (rewardsPerYear /
418
- (new Amount(distributorTotalSupply, vault.vaultToken.decimals)
419
- .value *
420
- vault.underlyingPrice)) *
421
- 100,
422
- token: token,
423
- });
412
+ const settlements = await Promise.allSettled(
413
+ group.map(async ({ vault, multiReward }) => {
414
+ const [rewards, distributorTotalSupply] = await Promise.all([
415
+ getRewardsData(environment, multiReward),
416
+ getTotalSupplyData(environment, multiReward),
417
+ ]);
418
+
419
+ rewards
420
+ .filter(
421
+ (reward) =>
422
+ reward?.periodFinish &&
423
+ dayjs.utc().isBefore(dayjs.unix(Number(reward.periodFinish))),
424
+ )
425
+ .forEach((reward) => {
426
+ const token = Object.values(environment.config.tokens).find(
427
+ (token) => token.address === reward?.token,
428
+ );
429
+ if (!token || !reward?.rewardRate) return;
430
+
431
+ const market = tokenPrices.find(
432
+ (m) => m?.token.address === reward.token,
433
+ );
434
+
435
+ const rewardPriceUsd = market?.tokenPrice.value ?? 0;
436
+
437
+ const rewardsPerYear =
438
+ new Amount(reward.rewardRate, market?.token.decimals ?? 18).value *
439
+ SECONDS_PER_YEAR *
440
+ rewardPriceUsd;
441
+
442
+ vault.stakingRewards.push({
443
+ apr:
444
+ (rewardsPerYear /
445
+ (new Amount(distributorTotalSupply, vault.vaultToken.decimals)
446
+ .value *
447
+ vault.underlyingPrice)) *
448
+ 100,
449
+ token: token,
424
450
  });
451
+ });
425
452
 
426
- vault.stakingRewardsApr = vault.stakingRewards.reduce(
427
- (acc, curr) => acc + curr.apr,
428
- 0,
429
- );
430
- vault.totalStakingApr = vault.stakingRewardsApr + vault.baseApy;
431
- } catch (error) {
432
- console.warn(
433
- `Failed to fetch staking rewards for vault ${vault.vaultKey}:`,
434
- error,
435
- );
436
- }
437
- }
438
- }
453
+ vault.stakingRewardsApr = vault.stakingRewards.reduce(
454
+ (acc, curr) => acc + curr.apr,
455
+ 0,
456
+ );
457
+ vault.totalStakingApr = vault.stakingRewardsApr + vault.baseApy;
458
+ }),
459
+ );
439
460
 
440
- return [...allVaults, ...fallbackVaults];
461
+ const failedVaultKeys = vaultKeys.filter(
462
+ (_, i) => settlements[i]?.status === "rejected",
463
+ );
464
+ const firstFailure = settlements.find(
465
+ (s): s is PromiseRejectedResult => s.status === "rejected",
466
+ );
467
+ if (firstFailure) {
468
+ console.warn(
469
+ `Failed to fetch staking rewards for vault(s) ${failedVaultKeys.join(", ")} (chain ${environment.chainId}):`,
470
+ firstFailure.reason,
471
+ );
472
+ environment.onError?.(firstFailure.reason, {
473
+ source: "morpho-vault-staking-rewards",
474
+ chainId: environment.chainId,
475
+ operation: "staking-rewards",
476
+ failedCount: failedVaultKeys.length,
477
+ totalCount: group.length,
478
+ items: failedVaultKeys,
479
+ });
480
+ }
441
481
  }
442
482
 
443
483
  export async function getMorphoVaultsData(params: {
@@ -1060,13 +1100,7 @@ async function getMorphoVaultsDataFromOnChain(params: {
1060
1100
  const data = await Promise.all([
1061
1101
  viewsContract?.read.getAllMarketsInfo(),
1062
1102
  homeViewsContract?.read.getNativeTokenPrice(),
1063
- getGovernanceTokenPriceFor(environment).catch((err) => {
1064
- environment.onError?.(err, {
1065
- source: "governance-token-price",
1066
- chainId: environment.chainId,
1067
- });
1068
- return 0n;
1069
- }),
1103
+ getGovernanceTokenPriceOrZero(environment),
1070
1104
  ]);
1071
1105
 
1072
1106
  const [allMarkets, nativeTokenPriceRaw, governanceTokenPriceRaw] = data;
@@ -248,13 +248,14 @@ export function transformVaultFromIndexer(
248
248
  // where totalLiquidity briefly exceeds totalAssets.
249
249
  const vaultSupplyValue = Math.max(0, totalAssetsValue - totalLiquidityValue);
250
250
 
251
- // Transform markets
252
- const markets = indexerVault.markets.map((market) =>
251
+ // Transform markets. The indexer has served vaults without `markets` /
252
+ // `rewards` (Sentry MOONWELL-FRONTEND-GX); keep the vault with empty lists.
253
+ const markets = (indexerVault.markets ?? []).map((market) =>
253
254
  transformMarket(market, underlyingDecimals, totalAssetsValue, tokenMap),
254
255
  );
255
256
 
256
257
  // Transform rewards
257
- const rewards = transformRewards(indexerVault.rewards, tokenMap);
258
+ const rewards = transformRewards(indexerVault.rewards ?? [], tokenMap);
258
259
 
259
260
  // Build vault object
260
261
  const vault: MorphoVault = {
@@ -313,9 +314,43 @@ export function transformVaultsFromIndexer(
313
314
  environment: Environment,
314
315
  tokenMap: Map<string, LunarIndexerToken>,
315
316
  ): MorphoVault[] {
316
- return indexerVaults.map((vault) =>
317
- transformVaultFromIndexer(vault, environment, tokenMap),
318
- );
317
+ // A malformed record costs only that record, never the chain's whole list.
318
+ // Missing `markets` / `rewards` keep the vault (as empty lists); a vault
319
+ // that cannot be built at all is dropped. Both are reported once per call.
320
+ const malformed: string[] = [];
321
+ let firstError: unknown;
322
+ const vaults = indexerVaults.flatMap((vault) => {
323
+ if (!Array.isArray(vault.markets) || !Array.isArray(vault.rewards)) {
324
+ malformed.push(vault.address);
325
+ firstError ??= new Error(
326
+ `Lunar Indexer vault ${vault.address} is missing ${!Array.isArray(vault.markets) ? "markets" : "rewards"}`,
327
+ );
328
+ }
329
+ try {
330
+ return [transformVaultFromIndexer(vault, environment, tokenMap)];
331
+ } catch (error) {
332
+ if (!malformed.includes(vault.address)) malformed.push(vault.address);
333
+ firstError ??= error;
334
+ return [];
335
+ }
336
+ });
337
+
338
+ if (malformed.length > 0) {
339
+ console.warn(
340
+ `Malformed Lunar Indexer vault record(s) on chain ${environment.chainId}: ${malformed.join(", ")}`,
341
+ firstError,
342
+ );
343
+ environment.onError?.(firstError, {
344
+ source: "vaults",
345
+ chainId: environment.chainId,
346
+ operation: "malformed-vault-records",
347
+ failedCount: malformed.length,
348
+ totalCount: indexerVaults.length,
349
+ items: malformed,
350
+ });
351
+ }
352
+
353
+ return vaults;
319
354
  }
320
355
 
321
356
  /**
@@ -228,11 +228,21 @@ export type ContractsConfig<contracts, tokens> = {} extends contracts
228
228
  ? {}
229
229
  : { [name in keyof ContractConfig<tokens>]?: ContractConfig<tokens>[name] };
230
230
 
231
- /** Context passed to `onError` callbacks. `token` is set for per-token read failures. */
231
+ /**
232
+ * Context passed to `onError` callbacks. `token` is set for per-token read failures.
233
+ *
234
+ * Batched reads report once per operation rather than once per item: `error`
235
+ * is the first failure, `failedCount`/`totalCount` size the batch and `items`
236
+ * identifies what failed (proposalIds, token/vault addresses, chainIds...).
237
+ */
232
238
  export type OnErrorContext = {
233
239
  source: string;
234
240
  chainId: number;
235
241
  token?: Address;
242
+ operation?: string;
243
+ failedCount?: number;
244
+ totalCount?: number;
245
+ items?: string[];
236
246
  };
237
247
 
238
248
  export type CustomConfig<custom> = {} extends custom
package/errors/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.24.0'
1
+ export const version = '0.25.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moonwell-fi/moonwell-sdk",
3
3
  "description": "TypeScript Interface for Moonwell",
4
- "version": "0.24.0",
4
+ "version": "0.25.0",
5
5
  "main": "./_cjs/index.js",
6
6
  "module": "./_esm/index.js",
7
7
  "types": "./_types/index.d.ts",