@imbingox/acex 0.3.0-beta.0 → 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 {
@@ -154,6 +174,7 @@ export interface CreateOrderRequest {
154
174
  type: CreateOrderType;
155
175
  amount: string;
156
176
  price?: string;
177
+ postOnly?: boolean;
157
178
  clientOrderId?: string;
158
179
  reduceOnly?: boolean;
159
180
  positionSide?: PositionSide;
@@ -170,6 +191,7 @@ export interface CancelAllOrdersRequest {
170
191
  }
171
192
 
172
193
  export interface PrivateStreamCallbacks {
194
+ onAccountSnapshot(snapshot: RawAccountBootstrap): void;
173
195
  onAccountUpdate(update: RawAccountUpdate): void;
174
196
  onOrderUpdate(update: RawOrderUpdate): void;
175
197
  onDisconnected(): void;
@@ -182,11 +204,12 @@ export interface PrivateStreamOptions {
182
204
  reconnectDelayMs: number;
183
205
  reconnectMaxDelayMs: number;
184
206
  listenKeyKeepAliveMs: number;
207
+ juplendPollIntervalMs?: number;
185
208
  now?: () => number;
186
209
  }
187
210
 
188
211
  export interface PrivateUserDataAdapter {
189
- readonly exchange: Exchange;
212
+ readonly venue: Venue;
190
213
  bootstrapAccount(
191
214
  credentials: AccountCredentials,
192
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