@imbingox/acex 0.3.0-beta.1 → 0.3.0-beta.2

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.
@@ -0,0 +1,483 @@
1
+ import BigNumber from "bignumber.js";
2
+ import { AcexError } from "../../errors.ts";
3
+ import type { AccountCredentials } from "../../types/index.ts";
4
+ import type {
5
+ CancelAllOrdersRequest,
6
+ CancelOrderRequest,
7
+ CreateOrderRequest,
8
+ PrivateStreamCallbacks,
9
+ PrivateStreamOptions,
10
+ PrivateUserDataAdapter,
11
+ RawAccountBootstrap,
12
+ RawBalanceUpdate,
13
+ RawOrderUpdate,
14
+ RawRiskUpdate,
15
+ StreamHandle,
16
+ } from "../types.ts";
17
+
18
+ interface JuplendPortfolioResponse {
19
+ elements?: JuplendPortfolioElement[];
20
+ }
21
+
22
+ interface JuplendPortfolioElement {
23
+ data?: {
24
+ link?: string;
25
+ suppliedValue?: number | string;
26
+ borrowedValue?: number | string;
27
+ value?: number | string;
28
+ };
29
+ }
30
+
31
+ interface JuplendVaultResponse {
32
+ data?: JuplendVault[];
33
+ }
34
+
35
+ interface JuplendVault {
36
+ id?: number | string;
37
+ vaultId?: number | string;
38
+ supplyToken?: JuplendToken;
39
+ borrowToken?: JuplendToken;
40
+ liquidationThreshold?: number | string;
41
+ loanToValue?: number | string;
42
+ supplyRate?: number | string;
43
+ borrowRate?: number | string;
44
+ }
45
+
46
+ interface JuplendToken {
47
+ symbol?: string;
48
+ asset?: string;
49
+ oraclePrice?: number | string;
50
+ price?: number | string;
51
+ }
52
+
53
+ interface JuplendMappedAccount {
54
+ balances: RawBalanceUpdate[];
55
+ risk?: RawRiskUpdate;
56
+ }
57
+
58
+ interface BalanceAccumulator {
59
+ asset: string;
60
+ supplied: BigNumber;
61
+ borrowed: BigNumber;
62
+ supplyAPY?: BigNumber;
63
+ borrowAPY?: BigNumber;
64
+ }
65
+
66
+ interface JuplendAccountOptions {
67
+ walletAddress: string;
68
+ positionId?: string;
69
+ }
70
+
71
+ const PORTFOLIO_BASE_URL = "https://api.jup.ag/portfolio/v1";
72
+ const VAULTS_URL = "https://lite-api.jup.ag/lend/v1/borrow/vaults";
73
+ const DEFAULT_POLL_INTERVAL_MS = 30_000;
74
+ const VAULT_CACHE_TTL_MS = 60 * 60 * 1_000;
75
+ const LINK_PATTERN = /\/borrow\/([^/]+)\/nfts\/([^/?#]+)/;
76
+
77
+ let vaultCache:
78
+ | {
79
+ loadedAt: number;
80
+ vaults: Map<string, JuplendVault>;
81
+ }
82
+ | undefined;
83
+ let vaultCachePromise: Promise<Map<string, JuplendVault>> | undefined;
84
+
85
+ function requireApiKey(credentials: AccountCredentials): string {
86
+ if (!credentials.apiKey) {
87
+ throw new Error("credentials.apiKey required");
88
+ }
89
+
90
+ return credentials.apiKey;
91
+ }
92
+
93
+ function getJuplendAccountOptions(
94
+ accountOptions?: Record<string, unknown>,
95
+ ): JuplendAccountOptions {
96
+ const walletAddress = accountOptions?.walletAddress;
97
+ if (typeof walletAddress !== "string" || !walletAddress) {
98
+ throw new Error("options.walletAddress required");
99
+ }
100
+
101
+ const positionId = accountOptions.positionId;
102
+ if (positionId !== undefined && typeof positionId !== "string") {
103
+ throw new Error("options.positionId must be a string");
104
+ }
105
+
106
+ return {
107
+ walletAddress,
108
+ positionId: positionId || undefined,
109
+ };
110
+ }
111
+
112
+ function toBigNumber(value: number | string | undefined): BigNumber {
113
+ return value === undefined ? new BigNumber(0) : new BigNumber(value);
114
+ }
115
+
116
+ function normalizeThreshold(value: number | string | undefined): BigNumber {
117
+ const threshold = toBigNumber(value);
118
+ return threshold.gt(1) ? threshold.dividedBy(1000) : threshold;
119
+ }
120
+
121
+ function tokenAsset(token: JuplendToken | undefined): string | undefined {
122
+ return token?.symbol ?? token?.asset;
123
+ }
124
+
125
+ function tokenPrice(token: JuplendToken | undefined): BigNumber | undefined {
126
+ const price = toBigNumber(token?.oraclePrice ?? token?.price);
127
+ return price.gt(0) ? price : undefined;
128
+ }
129
+
130
+ function extractPositionLink(
131
+ link: string | undefined,
132
+ ): { vaultId: string; positionId: string } | undefined {
133
+ if (!link) {
134
+ return undefined;
135
+ }
136
+
137
+ const match = LINK_PATTERN.exec(link);
138
+ if (!match?.[1] || !match[2]) {
139
+ return undefined;
140
+ }
141
+
142
+ return {
143
+ vaultId: match[1],
144
+ positionId: match[2],
145
+ };
146
+ }
147
+
148
+ function getVaultId(vault: JuplendVault): string | undefined {
149
+ const id = vault.id ?? vault.vaultId;
150
+ return id === undefined ? undefined : `${id}`;
151
+ }
152
+
153
+ function setAccumulator(
154
+ map: Map<string, BalanceAccumulator>,
155
+ asset: string,
156
+ ): BalanceAccumulator {
157
+ const existing = map.get(asset);
158
+ if (existing) {
159
+ return existing;
160
+ }
161
+
162
+ const next: BalanceAccumulator = {
163
+ asset,
164
+ supplied: new BigNumber(0),
165
+ borrowed: new BigNumber(0),
166
+ };
167
+ map.set(asset, next);
168
+ return next;
169
+ }
170
+
171
+ function buildBalances(
172
+ balances: Map<string, BalanceAccumulator>,
173
+ receivedAt: number,
174
+ ): RawBalanceUpdate[] {
175
+ return [...balances.values()].map((balance) => {
176
+ const netAsset = balance.supplied.minus(balance.borrowed);
177
+ return {
178
+ asset: balance.asset,
179
+ free: "0",
180
+ used: "0",
181
+ total: netAsset.toString(10),
182
+ receivedAt,
183
+ lending: {
184
+ supplied: balance.supplied.toString(10),
185
+ borrowed: balance.borrowed.toString(10),
186
+ interest: "0",
187
+ netAsset: netAsset.toString(10),
188
+ supplyAPY: balance.supplyAPY?.toString(10),
189
+ borrowAPY: balance.borrowAPY?.toString(10),
190
+ },
191
+ };
192
+ });
193
+ }
194
+
195
+ function buildRisk(input: {
196
+ totalCollateralUsd: BigNumber;
197
+ totalDebtUsd: BigNumber;
198
+ weightedLiquidationValueUsd: BigNumber;
199
+ receivedAt: number;
200
+ }): RawRiskUpdate | undefined {
201
+ const { totalCollateralUsd, totalDebtUsd, weightedLiquidationValueUsd } =
202
+ input;
203
+ if (totalCollateralUsd.isZero() && totalDebtUsd.isZero()) {
204
+ return undefined;
205
+ }
206
+
207
+ const riskRatio = weightedLiquidationValueUsd.isZero()
208
+ ? undefined
209
+ : totalDebtUsd.dividedBy(weightedLiquidationValueUsd).toString(10);
210
+ const ltv = totalCollateralUsd.isZero()
211
+ ? undefined
212
+ : totalDebtUsd.dividedBy(totalCollateralUsd).toString(10);
213
+ const liquidationThreshold = totalCollateralUsd.isZero()
214
+ ? undefined
215
+ : weightedLiquidationValueUsd.dividedBy(totalCollateralUsd).toString(10);
216
+ const healthFactor = riskRatio
217
+ ? new BigNumber(1).dividedBy(riskRatio).toString(10)
218
+ : undefined;
219
+
220
+ return {
221
+ equity: totalCollateralUsd.minus(totalDebtUsd).toString(10),
222
+ riskRatio,
223
+ receivedAt: input.receivedAt,
224
+ lending: {
225
+ healthFactor,
226
+ ltv,
227
+ liquidationThreshold,
228
+ totalCollateralUSD: totalCollateralUsd.toString(10),
229
+ totalDebtUSD: totalDebtUsd.toString(10),
230
+ },
231
+ };
232
+ }
233
+
234
+ async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
235
+ const response = await fetch(url, init);
236
+ if (!response.ok) {
237
+ throw new Error(`Juplend HTTP ${response.status}: ${response.statusText}`);
238
+ }
239
+
240
+ return (await response.json()) as T;
241
+ }
242
+
243
+ async function loadVaults(now: number): Promise<Map<string, JuplendVault>> {
244
+ if (vaultCache && now - vaultCache.loadedAt < VAULT_CACHE_TTL_MS) {
245
+ return vaultCache.vaults;
246
+ }
247
+
248
+ if (!vaultCachePromise) {
249
+ vaultCachePromise = readJson<JuplendVaultResponse | JuplendVault[]>(
250
+ VAULTS_URL,
251
+ )
252
+ .then((response) => {
253
+ const rawVaults = Array.isArray(response) ? response : response.data;
254
+ const vaults = new Map<string, JuplendVault>();
255
+ for (const vault of rawVaults ?? []) {
256
+ const id = getVaultId(vault);
257
+ if (id) {
258
+ vaults.set(id, vault);
259
+ }
260
+ }
261
+ vaultCache = { loadedAt: now, vaults };
262
+ return vaults;
263
+ })
264
+ .finally(() => {
265
+ vaultCachePromise = undefined;
266
+ });
267
+ }
268
+
269
+ try {
270
+ return await vaultCachePromise;
271
+ } catch (error) {
272
+ if (vaultCache) {
273
+ return vaultCache.vaults;
274
+ }
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ async function loadPortfolio(
280
+ walletAddress: string,
281
+ apiKey: string,
282
+ ): Promise<JuplendPortfolioResponse> {
283
+ return readJson<JuplendPortfolioResponse>(
284
+ `${PORTFOLIO_BASE_URL}/positions/${walletAddress}?platforms=jupiter-exchange`,
285
+ {
286
+ headers: {
287
+ "X-API-KEY": apiKey,
288
+ },
289
+ },
290
+ );
291
+ }
292
+
293
+ function mapAccount(
294
+ portfolio: JuplendPortfolioResponse,
295
+ vaults: Map<string, JuplendVault>,
296
+ receivedAt: number,
297
+ positionId?: string,
298
+ ): JuplendMappedAccount {
299
+ const balances = new Map<string, BalanceAccumulator>();
300
+ let totalCollateralUsd = new BigNumber(0);
301
+ let totalDebtUsd = new BigNumber(0);
302
+ let weightedLiquidationValueUsd = new BigNumber(0);
303
+
304
+ for (const element of portfolio.elements ?? []) {
305
+ const positionLink = extractPositionLink(element.data?.link);
306
+ if (!positionLink) {
307
+ continue;
308
+ }
309
+
310
+ if (positionId && positionLink.positionId !== positionId) {
311
+ continue;
312
+ }
313
+
314
+ const vault = vaults.get(positionLink.vaultId);
315
+ if (!vault) {
316
+ continue;
317
+ }
318
+
319
+ const suppliedValue = toBigNumber(element.data?.suppliedValue);
320
+ const borrowedValue = toBigNumber(element.data?.borrowedValue);
321
+ const liquidationThreshold = normalizeThreshold(
322
+ vault.liquidationThreshold ?? vault.loanToValue,
323
+ );
324
+ totalCollateralUsd = totalCollateralUsd.plus(suppliedValue);
325
+ totalDebtUsd = totalDebtUsd.plus(borrowedValue);
326
+ weightedLiquidationValueUsd = weightedLiquidationValueUsd.plus(
327
+ suppliedValue.multipliedBy(liquidationThreshold),
328
+ );
329
+
330
+ const supplyAsset = tokenAsset(vault.supplyToken);
331
+ const supplyPrice = tokenPrice(vault.supplyToken);
332
+ if (supplyAsset && supplyPrice) {
333
+ const accumulator = setAccumulator(balances, supplyAsset);
334
+ accumulator.supplied = accumulator.supplied.plus(
335
+ suppliedValue.dividedBy(supplyPrice),
336
+ );
337
+ accumulator.supplyAPY = toBigNumber(vault.supplyRate);
338
+ }
339
+
340
+ const borrowAsset = tokenAsset(vault.borrowToken);
341
+ const borrowPrice = tokenPrice(vault.borrowToken);
342
+ if (borrowAsset && borrowPrice) {
343
+ const accumulator = setAccumulator(balances, borrowAsset);
344
+ accumulator.borrowed = accumulator.borrowed.plus(
345
+ borrowedValue.dividedBy(borrowPrice),
346
+ );
347
+ accumulator.borrowAPY = toBigNumber(vault.borrowRate);
348
+ }
349
+ }
350
+
351
+ return {
352
+ balances: buildBalances(balances, receivedAt),
353
+ risk: buildRisk({
354
+ totalCollateralUsd,
355
+ totalDebtUsd,
356
+ weightedLiquidationValueUsd,
357
+ receivedAt,
358
+ }),
359
+ };
360
+ }
361
+
362
+ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
363
+ readonly venue = "juplend" as const;
364
+
365
+ async bootstrapAccount(
366
+ credentials: AccountCredentials,
367
+ accountOptions?: Record<string, unknown>,
368
+ ): Promise<RawAccountBootstrap> {
369
+ const receivedAt = Date.now();
370
+ const apiKey = requireApiKey(credentials);
371
+ const juplendOptions = getJuplendAccountOptions(accountOptions);
372
+ const [portfolio, vaults] = await Promise.all([
373
+ loadPortfolio(juplendOptions.walletAddress, apiKey),
374
+ loadVaults(receivedAt),
375
+ ]);
376
+ const mapped = mapAccount(
377
+ portfolio,
378
+ vaults,
379
+ receivedAt,
380
+ juplendOptions.positionId,
381
+ );
382
+
383
+ return {
384
+ balances: mapped.balances,
385
+ positions: [],
386
+ risk: mapped.risk,
387
+ receivedAt,
388
+ };
389
+ }
390
+
391
+ bootstrapOpenOrders(): Promise<RawOrderUpdate[]> {
392
+ return Promise.resolve([]);
393
+ }
394
+
395
+ createOrder(
396
+ _credentials: AccountCredentials,
397
+ _request: CreateOrderRequest,
398
+ ): Promise<RawOrderUpdate> {
399
+ throw new AcexError(
400
+ "VENUE_NOT_SUPPORTED",
401
+ "Juplend is read-only and does not support createOrder",
402
+ );
403
+ }
404
+
405
+ cancelOrder(
406
+ _credentials: AccountCredentials,
407
+ _request: CancelOrderRequest,
408
+ ): Promise<RawOrderUpdate> {
409
+ throw new AcexError(
410
+ "VENUE_NOT_SUPPORTED",
411
+ "Juplend is read-only and does not support cancelOrder",
412
+ );
413
+ }
414
+
415
+ cancelAllOrders(
416
+ _credentials: AccountCredentials,
417
+ _request: CancelAllOrdersRequest,
418
+ ): Promise<RawOrderUpdate[]> {
419
+ throw new AcexError(
420
+ "VENUE_NOT_SUPPORTED",
421
+ "Juplend is read-only and does not support cancelAllOrders",
422
+ );
423
+ }
424
+
425
+ createPrivateStream(
426
+ credentials: AccountCredentials,
427
+ callbacks: PrivateStreamCallbacks,
428
+ options: PrivateStreamOptions,
429
+ accountOptions?: Record<string, unknown>,
430
+ ): StreamHandle {
431
+ let closed = false;
432
+ let timer: ReturnType<typeof setTimeout> | undefined;
433
+ const pollIntervalMs =
434
+ options.juplendPollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
435
+
436
+ const poll = async (): Promise<void> => {
437
+ try {
438
+ const bootstrap = await this.bootstrapAccount(
439
+ credentials,
440
+ accountOptions,
441
+ );
442
+ if (closed) {
443
+ return;
444
+ }
445
+ callbacks.onAccountSnapshot(bootstrap);
446
+ } catch (error) {
447
+ callbacks.onError(
448
+ error instanceof Error ? error : new Error("Juplend polling failed"),
449
+ );
450
+ }
451
+ };
452
+
453
+ const scheduleNextPoll = (): void => {
454
+ if (closed) {
455
+ return;
456
+ }
457
+
458
+ timer = setTimeout(() => {
459
+ void poll().finally(scheduleNextPoll);
460
+ }, pollIntervalMs);
461
+ };
462
+
463
+ const ready = Promise.resolve().then(() => {
464
+ scheduleNextPoll();
465
+ });
466
+
467
+ return {
468
+ ready,
469
+ close() {
470
+ closed = true;
471
+ if (timer) {
472
+ clearTimeout(timer);
473
+ timer = undefined;
474
+ }
475
+ },
476
+ };
477
+ }
478
+ }
479
+
480
+ export function resetJuplendVaultCacheForTests(): void {
481
+ vaultCache = undefined;
482
+ vaultCachePromise = undefined;
483
+ }
@@ -1,11 +1,11 @@
1
1
  import type {
2
2
  AccountCredentials,
3
3
  CreateOrderType,
4
- Exchange,
5
4
  MarketDefinition,
6
5
  OrderSide,
7
6
  OrderStatus,
8
7
  PositionSide,
8
+ Venue,
9
9
  } from "../types/index.ts";
10
10
 
11
11
  export interface StreamHandle {
@@ -68,7 +68,7 @@ export interface FundingRateStreamOptions {
68
68
  }
69
69
 
70
70
  export interface MarketAdapter {
71
- readonly exchange: Exchange;
71
+ readonly venue: Venue;
72
72
  loadMarkets(): Promise<MarketDefinition[]>;
73
73
  createL1BookStream(
74
74
  market: MarketDefinition,
@@ -89,6 +89,16 @@ export interface RawBalanceUpdate {
89
89
  total?: string;
90
90
  exchangeTs?: number;
91
91
  receivedAt: number;
92
+ lending?: RawLendingBalanceUpdate;
93
+ }
94
+
95
+ export interface RawLendingBalanceUpdate {
96
+ supplied: string;
97
+ borrowed: string;
98
+ interest: string;
99
+ netAsset: string;
100
+ supplyAPY?: string;
101
+ borrowAPY?: string;
92
102
  }
93
103
 
94
104
  export interface RawPositionUpdate {
@@ -106,11 +116,21 @@ export interface RawPositionUpdate {
106
116
 
107
117
  export interface RawRiskUpdate {
108
118
  equity?: string;
109
- marginRatio?: string;
119
+ riskRatio?: string;
110
120
  initialMargin?: string;
111
121
  maintenanceMargin?: string;
112
122
  exchangeTs?: number;
113
123
  receivedAt: number;
124
+ lending?: RawLendingRiskUpdate;
125
+ }
126
+
127
+ export interface RawLendingRiskUpdate {
128
+ marginLevel?: string;
129
+ healthFactor?: string;
130
+ ltv?: string;
131
+ liquidationThreshold?: string;
132
+ totalCollateralUSD?: string;
133
+ totalDebtUSD?: string;
114
134
  }
115
135
 
116
136
  export interface RawAccountBootstrap {
@@ -171,6 +191,7 @@ export interface CancelAllOrdersRequest {
171
191
  }
172
192
 
173
193
  export interface PrivateStreamCallbacks {
194
+ onAccountSnapshot(snapshot: RawAccountBootstrap): void;
174
195
  onAccountUpdate(update: RawAccountUpdate): void;
175
196
  onOrderUpdate(update: RawOrderUpdate): void;
176
197
  onDisconnected(): void;
@@ -183,11 +204,12 @@ export interface PrivateStreamOptions {
183
204
  reconnectDelayMs: number;
184
205
  reconnectMaxDelayMs: number;
185
206
  listenKeyKeepAliveMs: number;
207
+ juplendPollIntervalMs?: number;
186
208
  now?: () => number;
187
209
  }
188
210
 
189
211
  export interface PrivateUserDataAdapter {
190
- readonly exchange: Exchange;
212
+ readonly venue: Venue;
191
213
  bootstrapAccount(
192
214
  credentials: AccountCredentials,
193
215
  accountOptions?: Record<string, unknown>,
@@ -9,15 +9,15 @@ import type {
9
9
  CancelAllOrdersInput,
10
10
  CancelOrderInput,
11
11
  CreateOrderInput,
12
- Exchange,
13
12
  HealthEvent,
14
13
  PrivateRuntimeReason,
15
14
  PrivateRuntimeStatus,
15
+ Venue,
16
16
  } from "../types/index.ts";
17
17
 
18
18
  export interface RegisteredAccountRecord {
19
19
  accountId: string;
20
- exchange: Exchange;
20
+ venue: Venue;
21
21
  credentials?: AccountCredentials;
22
22
  options?: Record<string, unknown>;
23
23
  }
@@ -49,7 +49,7 @@ export interface ManagerLifecycle {
49
49
 
50
50
  export interface AccountAwareManager {
51
51
  onAccountRemoved(accountId: string, now: number): void;
52
- onCredentialsUpdated(accountId: string, exchange: Exchange): void;
52
+ onCredentialsUpdated(accountId: string, venue: Venue): void;
53
53
  }
54
54
 
55
55
  export interface HealthReporter<T> {
@@ -65,46 +65,51 @@ export interface PrivateSubscriptionState {
65
65
  }
66
66
 
67
67
  export interface PrivateAccountDataConsumer {
68
- onPrivateAccountPending(accountId: string, exchange: Exchange): void;
68
+ onPrivateAccountPending(accountId: string, venue: Venue): void;
69
69
  onPrivateAccountBootstrap(
70
70
  accountId: string,
71
- exchange: Exchange,
71
+ venue: Venue,
72
72
  bootstrap: RawAccountBootstrap,
73
73
  ): void;
74
74
  onPrivateAccountUpdate(
75
75
  accountId: string,
76
- exchange: Exchange,
76
+ venue: Venue,
77
77
  update: RawAccountUpdate,
78
78
  ): void;
79
79
  onPrivateAccountStreamState(
80
80
  accountId: string,
81
- exchange: Exchange,
81
+ venue: Venue,
82
82
  state: PrivateSubscriptionState,
83
83
  ): void;
84
84
  }
85
85
 
86
86
  export interface PrivateOrderDataConsumer {
87
- onPrivateOrderPending(accountId: string, exchange: Exchange): void;
87
+ onPrivateOrderPending(accountId: string, venue: Venue): void;
88
88
  onPrivateOrderBootstrap(
89
89
  accountId: string,
90
- exchange: Exchange,
90
+ venue: Venue,
91
91
  snapshots: RawOrderUpdate[],
92
92
  ): void;
93
93
  onPrivateOrderUpdate(
94
94
  accountId: string,
95
- exchange: Exchange,
95
+ venue: Venue,
96
96
  update: RawOrderUpdate,
97
97
  ): void;
98
98
  onPrivateOrderStreamState(
99
99
  accountId: string,
100
- exchange: Exchange,
100
+ venue: Venue,
101
101
  state: PrivateSubscriptionState,
102
102
  ): void;
103
103
  }
104
104
 
105
105
  export function hasPrivateCredentials(
106
106
  credentials?: AccountCredentials,
107
+ venue?: Venue,
107
108
  ): boolean {
109
+ if (venue === "juplend") {
110
+ return Boolean(credentials?.apiKey);
111
+ }
112
+
108
113
  return Boolean(credentials?.apiKey && credentials.secret);
109
114
  }
110
115