@moonwell-fi/moonwell-sdk 0.9.26 → 0.9.28

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 (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/_cjs/actions/core/markets/common.js +291 -5
  3. package/_cjs/actions/core/markets/common.js.map +1 -1
  4. package/_cjs/actions/core/markets/getMarketSnapshots.js +83 -3
  5. package/_cjs/actions/core/markets/getMarketSnapshots.js.map +1 -1
  6. package/_cjs/actions/core/user-positions/common.js +61 -1
  7. package/_cjs/actions/core/user-positions/common.js.map +1 -1
  8. package/_cjs/actions/core/user-positions/getUserPositionSnapshots.js +70 -2
  9. package/_cjs/actions/core/user-positions/getUserPositionSnapshots.js.map +1 -1
  10. package/_cjs/actions/lunar-indexer-client.js +164 -0
  11. package/_cjs/actions/lunar-indexer-client.js.map +1 -0
  12. package/_cjs/actions/lunar-indexer-transformers.js +47 -0
  13. package/_cjs/actions/lunar-indexer-transformers.js.map +1 -0
  14. package/_cjs/environments/definitions/base/environment.js +2 -1
  15. package/_cjs/environments/definitions/base/environment.js.map +1 -1
  16. package/_cjs/environments/definitions/moonbeam/environment.js +2 -1
  17. package/_cjs/environments/definitions/moonbeam/environment.js.map +1 -1
  18. package/_cjs/environments/definitions/optimism/environment.js +2 -1
  19. package/_cjs/environments/definitions/optimism/environment.js.map +1 -1
  20. package/_cjs/environments/types/config.js +1 -0
  21. package/_cjs/environments/types/config.js.map +1 -1
  22. package/_cjs/errors/version.js +1 -1
  23. package/_cjs/utils/lunar-indexer-helpers.js +27 -0
  24. package/_cjs/utils/lunar-indexer-helpers.js.map +1 -0
  25. package/_esm/actions/core/markets/common.js +302 -5
  26. package/_esm/actions/core/markets/common.js.map +1 -1
  27. package/_esm/actions/core/markets/getMarketSnapshots.js +87 -3
  28. package/_esm/actions/core/markets/getMarketSnapshots.js.map +1 -1
  29. package/_esm/actions/core/user-positions/common.js +74 -1
  30. package/_esm/actions/core/user-positions/common.js.map +1 -1
  31. package/_esm/actions/core/user-positions/getUserPositionSnapshots.js +100 -2
  32. package/_esm/actions/core/user-positions/getUserPositionSnapshots.js.map +1 -1
  33. package/_esm/actions/lunar-indexer-client.js +201 -0
  34. package/_esm/actions/lunar-indexer-client.js.map +1 -0
  35. package/_esm/actions/lunar-indexer-transformers.js +80 -0
  36. package/_esm/actions/lunar-indexer-transformers.js.map +1 -0
  37. package/_esm/environments/definitions/base/environment.js +2 -1
  38. package/_esm/environments/definitions/base/environment.js.map +1 -1
  39. package/_esm/environments/definitions/moonbeam/environment.js +2 -1
  40. package/_esm/environments/definitions/moonbeam/environment.js.map +1 -1
  41. package/_esm/environments/definitions/optimism/environment.js +2 -1
  42. package/_esm/environments/definitions/optimism/environment.js.map +1 -1
  43. package/_esm/environments/types/config.js +1 -0
  44. package/_esm/environments/types/config.js.map +1 -1
  45. package/_esm/errors/version.js +1 -1
  46. package/_esm/utils/lunar-indexer-helpers.js +48 -0
  47. package/_esm/utils/lunar-indexer-helpers.js.map +1 -0
  48. package/_types/actions/core/markets/common.d.ts.map +1 -1
  49. package/_types/actions/core/markets/getMarketSnapshots.d.ts +4 -0
  50. package/_types/actions/core/markets/getMarketSnapshots.d.ts.map +1 -1
  51. package/_types/actions/core/user-positions/common.d.ts.map +1 -1
  52. package/_types/actions/core/user-positions/getUserPositionSnapshots.d.ts +28 -0
  53. package/_types/actions/core/user-positions/getUserPositionSnapshots.d.ts.map +1 -1
  54. package/_types/actions/lunar-indexer-client.d.ts +197 -0
  55. package/_types/actions/lunar-indexer-client.d.ts.map +1 -0
  56. package/_types/actions/lunar-indexer-transformers.d.ts +40 -0
  57. package/_types/actions/lunar-indexer-transformers.d.ts.map +1 -0
  58. package/_types/environments/definitions/base/environment.d.ts +1 -1
  59. package/_types/environments/definitions/base/environment.d.ts.map +1 -1
  60. package/_types/environments/definitions/moonbeam/environment.d.ts +1 -1
  61. package/_types/environments/definitions/moonbeam/environment.d.ts.map +1 -1
  62. package/_types/environments/definitions/optimism/environment.d.ts +1 -1
  63. package/_types/environments/definitions/optimism/environment.d.ts.map +1 -1
  64. package/_types/environments/types/config.d.ts +2 -0
  65. package/_types/environments/types/config.d.ts.map +1 -1
  66. package/_types/errors/version.d.ts +1 -1
  67. package/_types/utils/lunar-indexer-helpers.d.ts +38 -0
  68. package/_types/utils/lunar-indexer-helpers.d.ts.map +1 -0
  69. package/actions/core/markets/common.ts +500 -5
  70. package/actions/core/markets/getMarketSnapshots.ts +153 -2
  71. package/actions/core/user-positions/common.ts +139 -6
  72. package/actions/core/user-positions/getUserPositionSnapshots.ts +175 -1
  73. package/actions/lunar-indexer-client.ts +409 -0
  74. package/actions/lunar-indexer-transformers.ts +113 -0
  75. package/environments/definitions/base/environment.ts +3 -0
  76. package/environments/definitions/moonbeam/environment.ts +3 -0
  77. package/environments/definitions/optimism/environment.ts +3 -0
  78. package/environments/types/config.ts +3 -0
  79. package/errors/version.ts +1 -1
  80. package/package.json +1 -1
  81. package/utils/lunar-indexer-helpers.ts +57 -0
@@ -10,6 +10,13 @@ import {
10
10
  import type { NetworkParameterType } from "../../../common/types.js";
11
11
  import type { Chain, Environment } from "../../../environments/index.js";
12
12
  import type { MarketSnapshot } from "../../../types/market.js";
13
+ import { buildMarketId } from "../../../utils/lunar-indexer-helpers.js";
14
+ import {
15
+ DEFAULT_LUNAR_TIMEOUT_MS,
16
+ createLunarIndexerClient,
17
+ shouldFallback,
18
+ } from "../../lunar-indexer-client.js";
19
+ import { transformMarketSnapshots } from "../../lunar-indexer-transformers.js";
13
20
  import { getSubgraph } from "../../morpho/utils/graphql.js";
14
21
 
15
22
  dayjs.extend(utc);
@@ -20,8 +27,50 @@ export type GetMarketSnapshotsParameters<
20
27
  > = NetworkParameterType<environments, network> & {
21
28
  type: "core" | "isolated";
22
29
  marketId: `0x${string}`;
30
+ /** Predefined time period for snapshots */
31
+ period?: "1M" | "3M" | "1Y" | "ALL";
32
+ startTime?: number;
33
+ endTime?: number;
23
34
  };
24
35
 
36
+ /**
37
+ * Calculate start and end times based on period or custom timestamps.
38
+ * Priority: custom timestamps > period > default (365 days)
39
+ */
40
+ function calculateTimeRange(
41
+ period?: "1M" | "3M" | "1Y" | "ALL",
42
+ startTime?: number,
43
+ endTime?: number,
44
+ ): { startTime: number; endTime: number } {
45
+ const now = dayjs.utc();
46
+ const end = endTime ?? now.unix();
47
+
48
+ if (startTime !== undefined && endTime !== undefined) {
49
+ return { startTime, endTime: end };
50
+ }
51
+
52
+ let start: number;
53
+ switch (period) {
54
+ case "1M":
55
+ start = now.subtract(31, "days").unix();
56
+ break;
57
+ case "3M":
58
+ start = now.subtract(91, "days").unix();
59
+ break;
60
+ case "1Y":
61
+ start = now.subtract(366, "days").unix();
62
+ break;
63
+ case "ALL":
64
+ start = now.subtract(10, "years").unix();
65
+ break;
66
+ default:
67
+ start = now.subtract(365, "days").unix();
68
+ break;
69
+ }
70
+
71
+ return { startTime: start, endTime: end };
72
+ }
73
+
25
74
  export type GetMarketSnapshotsReturnType = Promise<MarketSnapshot[]>;
26
75
 
27
76
  export async function getMarketSnapshots<
@@ -38,7 +87,13 @@ export async function getMarketSnapshots<
38
87
  }
39
88
 
40
89
  if (args?.type === "core") {
41
- return fetchCoreMarketSnapshots(args.marketId, environment);
90
+ return fetchCoreMarketSnapshots(
91
+ args.marketId,
92
+ environment,
93
+ args.period,
94
+ args.startTime,
95
+ args.endTime,
96
+ );
42
97
  } else {
43
98
  if (environment.custom.morpho?.minimalDeployment === false) {
44
99
  return fetchIsolatedMarketSnapshots(args.marketId, environment);
@@ -51,6 +106,102 @@ export async function getMarketSnapshots<
51
106
  async function fetchCoreMarketSnapshots(
52
107
  marketAddress: string,
53
108
  environment: Environment,
109
+ period?: "1M" | "3M" | "1Y" | "ALL",
110
+ startTime?: number,
111
+ endTime?: number,
112
+ ): Promise<MarketSnapshot[]> {
113
+ if (environment.lunarIndexerUrl) {
114
+ try {
115
+ const result = await fetchCoreMarketSnapshotsFromLunar(
116
+ marketAddress,
117
+ environment,
118
+ period,
119
+ startTime,
120
+ endTime,
121
+ );
122
+ return result;
123
+ } catch (error) {
124
+ if (!shouldFallback(error)) {
125
+ throw error;
126
+ }
127
+ console.debug(
128
+ "[Lunar fallback] Falling back to Ponder for snapshots:",
129
+ error,
130
+ );
131
+ }
132
+ }
133
+
134
+ const result = await fetchCoreMarketSnapshotsFromPonder(
135
+ marketAddress,
136
+ environment,
137
+ );
138
+ return result;
139
+ }
140
+
141
+ async function fetchCoreMarketSnapshotsFromLunar(
142
+ marketAddress: string,
143
+ environment: Environment,
144
+ period?: "1M" | "3M" | "1Y" | "ALL",
145
+ customStartTime?: number,
146
+ customEndTime?: number,
147
+ ): Promise<MarketSnapshot[]> {
148
+ if (!environment.lunarIndexerUrl) {
149
+ throw new Error("Lunar Indexer URL not configured");
150
+ }
151
+
152
+ const client = createLunarIndexerClient({
153
+ baseUrl: environment.lunarIndexerUrl,
154
+ timeout: DEFAULT_LUNAR_TIMEOUT_MS,
155
+ });
156
+
157
+ const marketId = buildMarketId(environment.chainId, marketAddress);
158
+ const { startTime } = calculateTimeRange(
159
+ period,
160
+ customStartTime,
161
+ customEndTime,
162
+ );
163
+
164
+ const allSnapshots: MarketSnapshot[] = [];
165
+ let cursor: string | null = null;
166
+
167
+ do {
168
+ const response = await client.getMarketSnapshots(marketId, {
169
+ limit: 1000,
170
+ ...(cursor && { cursor }),
171
+ granularity: "1d",
172
+ startTime,
173
+ });
174
+
175
+ const transformed = transformMarketSnapshots(
176
+ response.results,
177
+ environment.chainId,
178
+ );
179
+
180
+ const filteredSnapshots = transformed.filter((snapshot: MarketSnapshot) =>
181
+ isStartOfDay(Math.floor(snapshot.timestamp / 1000)),
182
+ );
183
+
184
+ allSnapshots.push(...filteredSnapshots);
185
+
186
+ cursor = response.nextCursor;
187
+ } while (cursor !== null);
188
+
189
+ return allSnapshots.map((snapshot) => {
190
+ const supplied = snapshot.totalSupply;
191
+ const suppliedUsd = snapshot.totalSupplyUsd;
192
+ const price = supplied > 0 ? suppliedUsd / supplied : 0;
193
+
194
+ return {
195
+ ...snapshot,
196
+ collateralTokenPrice: price,
197
+ loanTokenPrice: price,
198
+ };
199
+ });
200
+ }
201
+
202
+ async function fetchCoreMarketSnapshotsFromPonder(
203
+ marketAddress: string,
204
+ environment: Environment,
54
205
  ): Promise<MarketSnapshot[]> {
55
206
  const dailyData: MarketDailyData[] = [];
56
207
  let hasNextPage = true;
@@ -82,7 +233,7 @@ async function fetchCoreMarketSnapshots(
82
233
  }>(environment.indexerUrl, {
83
234
  query: `
84
235
  query {
85
- marketDailySnapshots (
236
+ marketDailySnapshots (
86
237
  limit: 1000,
87
238
  orderBy: "timestamp"
88
239
  orderDirection: "desc"
@@ -16,12 +16,35 @@ export const getUserPositionData = async (params: {
16
16
  }
17
17
 
18
18
  try {
19
- const [allMarkets, balances, borrows, memberships] = await Promise.all([
20
- viewsContract.read.getAllMarketsInfo(),
21
- viewsContract.read.getUserBalances([params.account]),
22
- viewsContract.read.getUserBorrowsBalances([params.account]),
23
- viewsContract.read.getUserMarketsMemberships([params.account]),
24
- ]);
19
+ const [allMarketsResult, balancesResult, borrowsResult, membershipsResult] =
20
+ await Promise.allSettled([
21
+ viewsContract.read.getAllMarketsInfo(),
22
+ viewsContract.read.getUserBalances([params.account]),
23
+ viewsContract.read.getUserBorrowsBalances([params.account]),
24
+ viewsContract.read.getUserMarketsMemberships([params.account]),
25
+ ]);
26
+
27
+ const balances =
28
+ balancesResult.status === "fulfilled" ? balancesResult.value : [];
29
+ const borrows =
30
+ borrowsResult.status === "fulfilled" ? borrowsResult.value : [];
31
+ const memberships =
32
+ membershipsResult.status === "fulfilled" ? membershipsResult.value : [];
33
+
34
+ // If getAllMarketsInfo failed (e.g. broken on-chain oracle), fall back to
35
+ // per-mToken exchange rate calls. The user balance/borrow/membership calls
36
+ // don't touch the oracle so they can still succeed.
37
+ if (allMarketsResult.status === "rejected") {
38
+ return getUserPositionsFromMTokenFallback(
39
+ params,
40
+ balances as { amount: bigint; token: `0x${string}` }[],
41
+ borrows as { amount: bigint; token: `0x${string}` }[],
42
+ memberships as { membership: boolean; token: `0x${string}` }[],
43
+ );
44
+ }
45
+
46
+ const allMarkets = allMarketsResult.value;
47
+
25
48
  const markets = allMarkets
26
49
  ?.map((marketInfo) => {
27
50
  const market = findMarketByAddress(
@@ -104,3 +127,113 @@ export const getUserPositionData = async (params: {
104
127
  return [];
105
128
  }
106
129
  };
130
+
131
+ /**
132
+ * Fallback for chains whose on-chain price oracle is non-functional (e.g.
133
+ * deprecated Moonriver). getUserBalances/getUserBorrowsBalances/getUserMarketsMemberships
134
+ * don't require the oracle, so we use those results directly. We fetch each
135
+ * mToken's exchangeRate individually to convert mToken balances to underlying.
136
+ * All USD values are set to 0 since oracle prices are unavailable.
137
+ */
138
+ async function getUserPositionsFromMTokenFallback(
139
+ params: {
140
+ environment: Environment;
141
+ account: Address;
142
+ markets?: string[] | undefined;
143
+ },
144
+ balances: { amount: bigint; token: `0x${string}` }[],
145
+ borrows: { amount: bigint; token: `0x${string}` }[],
146
+ memberships: { membership: boolean; token: `0x${string}` }[],
147
+ ): Promise<UserPosition[]> {
148
+ const positions: UserPosition[] = [];
149
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
+ const envAny = params.environment as any;
151
+
152
+ for (const marketKey of Object.keys(params.environment.config.markets)) {
153
+ const marketConfig = envAny.config.markets[marketKey] as
154
+ | { underlyingToken: string; marketToken: string }
155
+ | undefined;
156
+ if (!marketConfig) continue;
157
+
158
+ const underlyingToken = envAny.config.tokens[
159
+ marketConfig.underlyingToken
160
+ ] as
161
+ | {
162
+ address: `0x${string}`;
163
+ decimals: number;
164
+ symbol: string;
165
+ name: string;
166
+ }
167
+ | undefined;
168
+ const marketToken = envAny.config.tokens[marketConfig.marketToken] as
169
+ | {
170
+ address: `0x${string}`;
171
+ decimals: number;
172
+ symbol: string;
173
+ name: string;
174
+ }
175
+ | undefined;
176
+ if (!underlyingToken || !marketToken) continue;
177
+
178
+ const mTokenAddress = marketToken.address.toLowerCase() as `0x${string}`;
179
+
180
+ const marketSuppliedRaw =
181
+ balances.find((r) => r.token.toLowerCase() === mTokenAddress)?.amount ??
182
+ 0n;
183
+ const marketBorrowedRaw =
184
+ borrows.find((r) => r.token.toLowerCase() === mTokenAddress)?.amount ??
185
+ 0n;
186
+
187
+ // Skip markets where the user has no position
188
+ if (marketSuppliedRaw === 0n && marketBorrowedRaw === 0n) continue;
189
+
190
+ const marketCollateralEnabled =
191
+ memberships.find((r) => r.token.toLowerCase() === mTokenAddress)
192
+ ?.membership === true;
193
+
194
+ // Fetch exchange rate individually (not oracle-dependent)
195
+ const mTokenContract = envAny.markets[marketKey] as
196
+ | { read: Record<string, (...args: unknown[]) => Promise<bigint>> }
197
+ | undefined;
198
+ const defaultExchangeRate = 10n ** BigInt(10 + underlyingToken.decimals);
199
+ let exchangeRateRaw: bigint;
200
+ try {
201
+ exchangeRateRaw =
202
+ (await mTokenContract?.read.exchangeRateStored()) ??
203
+ defaultExchangeRate;
204
+ } catch {
205
+ exchangeRateRaw = defaultExchangeRate;
206
+ }
207
+
208
+ const exchangeRate = new Amount(
209
+ exchangeRateRaw,
210
+ 10 + underlyingToken.decimals,
211
+ ).value;
212
+
213
+ const borrowed = new Amount(marketBorrowedRaw, underlyingToken.decimals);
214
+ const marketSupplied = new Amount(marketSuppliedRaw, marketToken.decimals);
215
+ const supplied = new Amount(
216
+ marketSupplied.value * exchangeRate,
217
+ underlyingToken.decimals,
218
+ );
219
+
220
+ if (params.markets && !params.markets.includes(marketToken.address)) {
221
+ continue;
222
+ }
223
+
224
+ positions.push({
225
+ chainId: params.environment.chainId,
226
+ account: params.account,
227
+ market: marketToken,
228
+ collateralEnabled: marketCollateralEnabled,
229
+ borrowed,
230
+ borrowedUsd: 0,
231
+ collateral: new Amount(0n, underlyingToken.decimals),
232
+ collateralUsd: 0,
233
+ supplied,
234
+ suppliedUsd: 0,
235
+ });
236
+ }
237
+
238
+ return positions;
239
+ }
@@ -7,6 +7,12 @@ import { getEnvironmentFromArgs, isStartOfDay } from "../../../common/index.js";
7
7
  import type { NetworkParameterType } from "../../../common/types.js";
8
8
  import type { Chain, Environment } from "../../../environments/index.js";
9
9
  import type { UserPositionSnapshot } from "../../../types/userPosition.js";
10
+ import {
11
+ DEFAULT_LUNAR_TIMEOUT_MS,
12
+ createLunarIndexerClient,
13
+ shouldFallback,
14
+ } from "../../lunar-indexer-client.js";
15
+ import { transformPortfolioToSnapshots } from "../../lunar-indexer-transformers.js";
10
16
 
11
17
  dayjs.extend(utc);
12
18
 
@@ -16,12 +22,82 @@ export type GetUserPositionSnapshotsParameters<
16
22
  > = NetworkParameterType<environments, network> & {
17
23
  /** User address*/
18
24
  userAddress: Address;
25
+ /** Predefined time period for snapshots */
26
+ period?: "1M" | "3M" | "1Y" | "ALL";
27
+ /** Custom start time (unix timestamp in seconds). Overrides period if both startTime and endTime are provided. */
28
+ startTime?: number;
29
+ /** Custom end time (unix timestamp in seconds). Overrides period if both startTime and endTime are provided. */
30
+ endTime?: number;
31
+ /** Data granularity. Defaults to "1d" */
32
+ granularity?: "1h" | "6h" | "1d";
19
33
  };
20
34
 
21
35
  export type GetUserPositionSnapshotsReturnType = Promise<
22
36
  UserPositionSnapshot[]
23
37
  >;
24
38
 
39
+ /**
40
+ * Calculate start and end times based on period or custom timestamps
41
+ * Priority: custom timestamps > period > default (365 days)
42
+ */
43
+ function calculateTimeRange(
44
+ period?: "1M" | "3M" | "1Y" | "ALL",
45
+ startTime?: number,
46
+ endTime?: number,
47
+ ): { startTime: number; endTime: number } {
48
+ const now = dayjs.utc();
49
+ const end = endTime ?? now.unix();
50
+
51
+ // If both startTime and endTime are provided, use them (custom range)
52
+ if (startTime !== undefined && endTime !== undefined) {
53
+ return { startTime, endTime: end };
54
+ }
55
+
56
+ // Calculate based on period
57
+ let start: number;
58
+ switch (period) {
59
+ case "1M":
60
+ start = now.subtract(31, "days").unix();
61
+ break;
62
+ case "3M":
63
+ start = now.subtract(91, "days").unix();
64
+ break;
65
+ case "1Y":
66
+ start = now.subtract(366, "days").unix();
67
+ break;
68
+ case "ALL":
69
+ // Use a date far in the past to get all available data
70
+ start = now.subtract(10, "years").unix();
71
+ break;
72
+ default:
73
+ // Default to 365 days for backward compatibility
74
+ start = now.subtract(365, "days").unix();
75
+ break;
76
+ }
77
+
78
+ return { startTime: start, endTime: end };
79
+ }
80
+
81
+ /**
82
+ * Get historical snapshots of a user's positions across all markets
83
+ *
84
+ * @param client - Moonwell client instance
85
+ * @param args - Parameters including user address and optional time range
86
+ * @param args.userAddress - The user's wallet address
87
+ * @param args.period - Predefined time period: "1M" (31 days), "3M" (91 days), "1Y" (366 days), or "ALL" (all available history)
88
+ * @param args.startTime - Custom start time (unix timestamp in seconds). Overrides period if both startTime and endTime are provided.
89
+ * @param args.endTime - Custom end time (unix timestamp in seconds). Overrides period if both startTime and endTime are provided.
90
+ * @param args.granularity - Data granularity: "1h", "6h", or "1d" (default). Determines snapshot frequency.
91
+ *
92
+ * @returns Array of user position snapshots with USD values for supply, borrow, and collateral
93
+ *
94
+ * @remarks
95
+ * - Default behavior (no time parameters): Returns 365 days of history
96
+ * - Parameter priority: Custom timestamps > period > default (365 days)
97
+ * - When using Lunar Indexer, custom time ranges are supported
98
+ * - When falling back to Ponder, all available data is returned (client-side filtering may be needed)
99
+ * - Snapshots are filtered to start-of-day for "1d" granularity
100
+ */
25
101
  export async function getUserPositionSnapshots<
26
102
  environments,
27
103
  Network extends Chain | undefined,
@@ -35,12 +111,110 @@ export async function getUserPositionSnapshots<
35
111
  return [];
36
112
  }
37
113
 
38
- return fetchUserPositionSnapshots(args.userAddress, environment);
114
+ return fetchUserPositionSnapshots(
115
+ args.userAddress,
116
+ environment,
117
+ args.period,
118
+ args.startTime,
119
+ args.endTime,
120
+ args.granularity,
121
+ );
39
122
  }
40
123
 
41
124
  async function fetchUserPositionSnapshots(
42
125
  userAddress: Address,
43
126
  environment: Environment,
127
+ period?: "1M" | "3M" | "1Y" | "ALL",
128
+ startTime?: number,
129
+ endTime?: number,
130
+ granularity?: "1h" | "6h" | "1d",
131
+ ): Promise<UserPositionSnapshot[]> {
132
+ if (environment.lunarIndexerUrl) {
133
+ try {
134
+ const result = await fetchUserPositionSnapshotsFromLunar(
135
+ userAddress,
136
+ environment,
137
+ period,
138
+ startTime,
139
+ endTime,
140
+ granularity,
141
+ );
142
+ return result;
143
+ } catch (error) {
144
+ if (!shouldFallback(error)) {
145
+ throw error;
146
+ }
147
+ console.debug(
148
+ "[Lunar fallback] Falling back to Ponder for user snapshots:",
149
+ error,
150
+ );
151
+ }
152
+ }
153
+
154
+ // Ponder fallback returns all available data (doesn't support time filtering)
155
+ const result = await fetchUserPositionSnapshotsFromPonder(
156
+ userAddress,
157
+ environment,
158
+ );
159
+ return result;
160
+ }
161
+
162
+ async function fetchUserPositionSnapshotsFromLunar(
163
+ userAddress: Address,
164
+ environment: Environment,
165
+ period?: "1M" | "3M" | "1Y" | "ALL",
166
+ customStartTime?: number,
167
+ customEndTime?: number,
168
+ granularity: "1h" | "6h" | "1d" = "1d",
169
+ ): Promise<UserPositionSnapshot[]> {
170
+ if (!environment.lunarIndexerUrl) {
171
+ throw new Error("Lunar Indexer URL not configured");
172
+ }
173
+
174
+ const client = createLunarIndexerClient({
175
+ baseUrl: environment.lunarIndexerUrl,
176
+ timeout: DEFAULT_LUNAR_TIMEOUT_MS,
177
+ });
178
+
179
+ const { startTime, endTime } = calculateTimeRange(
180
+ period,
181
+ customStartTime,
182
+ customEndTime,
183
+ );
184
+
185
+ const portfolio = await client.getAccountPortfolio(
186
+ userAddress.toLowerCase(),
187
+ {
188
+ startTime,
189
+ endTime,
190
+ granularity,
191
+ chainId: environment.chainId,
192
+ },
193
+ );
194
+
195
+ const snapshots = transformPortfolioToSnapshots(
196
+ portfolio,
197
+ environment.chainId,
198
+ );
199
+
200
+ // Find the first snapshot where user has any position
201
+ const firstNonZeroIndex = snapshots.findIndex(
202
+ (snapshot) =>
203
+ snapshot.totalSupplyUsd > 0 ||
204
+ snapshot.totalBorrowsUsd > 0 ||
205
+ snapshot.totalCollateralUsd > 0,
206
+ );
207
+
208
+ if (firstNonZeroIndex === -1) {
209
+ return [];
210
+ }
211
+
212
+ return snapshots.slice(firstNonZeroIndex);
213
+ }
214
+
215
+ async function fetchUserPositionSnapshotsFromPonder(
216
+ userAddress: Address,
217
+ environment: Environment,
44
218
  ): Promise<UserPositionSnapshot[]> {
45
219
  const dailyData: UserDailyData[] = [];
46
220
  let hasNextPage = true;