@imbingox/acex 0.3.0-beta.5 → 0.3.0-beta.6

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.
package/README.md CHANGED
@@ -60,7 +60,7 @@ await client.stop();
60
60
 
61
61
  ### 同一个 client 同时使用 Binance + Juplend
62
62
 
63
- `createClient({ account: { binance: { riskPollIntervalMs }, juplend: { pollIntervalMs } } })` 只是分别配置 Binance 风险/仓位校准间隔和 Juplend 账户 polling 间隔,不代表这个 client 只能注册某个 venue。一个 `AcexClient` 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户,用同一个 `AccountManager` 对比风险值。
63
+ `createClient({ account: { binance: { riskPollIntervalMs }, juplend: { pollIntervalMs, rpcUrl, jupApiKey } } })` 只是分别配置 Binance 风险/仓位校准间隔和 Juplend 账户 polling / RPC / Jup API,不代表这个 client 只能注册某个 venue。一个 `AcexClient` 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户,用同一个 `AccountManager` 对比风险值。
64
64
 
65
65
  ```ts
66
66
  const client = createClient({
@@ -70,6 +70,8 @@ const client = createClient({
70
70
  },
71
71
  juplend: {
72
72
  pollIntervalMs: 30_000,
73
+ rpcUrl: process.env.SOL_HELIUS_RPC,
74
+ jupApiKey: process.env.JUP_API,
73
75
  },
74
76
  },
75
77
  });
@@ -87,15 +89,21 @@ await client.registerAccount({
87
89
  await client.registerAccount({
88
90
  accountId: "jup-loop-a",
89
91
  venue: "juplend",
90
- credentials: {
91
- apiKey: process.env.JUPITER_API_KEY!,
92
- },
93
92
  options: {
94
93
  walletAddress: "<solana-wallet-address>",
95
94
  positionId: "<optional-nft-position-id>",
96
95
  },
97
96
  });
98
97
 
98
+ await client.registerAccount({
99
+ accountId: "jup-loop-direct",
100
+ venue: "juplend",
101
+ options: {
102
+ vaultId: "<vault-id>",
103
+ positionId: "<nft-position-id>",
104
+ },
105
+ });
106
+
99
107
  await client.account.subscribeAccount({ accountId: "jup-loop-a" });
100
108
  await client.account.subscribeAccount({ accountId: "main-binance" });
101
109
  await client.order.subscribeOrders({ accountId: "main-binance" });
@@ -115,7 +123,7 @@ console.log({
115
123
  await client.stop();
116
124
  ```
117
125
 
118
- Juplend 使用 Jupiter Portfolio API 读取 Solana 钱包的借贷仓位,不需要私钥,也不支持 supply / borrow / repay / withdraw 等写操作。`accountId` 是你自定义的 SDK 账户名;Solana 钱包地址放在 `options.walletAddress`。如果只想观察某个 Juplend NFT position,可传 `options.positionId`。
126
+ Juplend 使用 `@jup-ag/lend-read` 通过 Solana RPC 读取原生借贷仓位,不需要私钥,也不支持 supply / borrow / repay / withdraw 等写操作。`accountId` 是你自定义的 SDK 账户名。聚合钱包全部仓位时传 `options.walletAddress`;只想观察单个仓位时,可直接传 `options.vaultId + options.positionId`,这样不会先扫整个钱包。`account.juplend.rpcUrl` 可显式指定 RPC;未指定时默认读取 `SOL_HELIUS_RPC`,再 fallback 到 SDK 默认 RPC。token metadata / price 优先走 Jup 官方 `Tokens V2 + Price V3`,可通过 `account.juplend.jupApiKey` 或环境变量 `JUP_API` 注入;拿不到时退回 lite vault metadata。
119
127
 
120
128
  ### 查询 venue 能力
121
129
 
@@ -151,7 +159,7 @@ const capabilities = client.listVenueCapabilities();
151
159
 
152
160
  - 运行时 market/order 能力只支持 `binance`;`okx` / `bybit` / `gate` 仅类型定义
153
161
  - 账户视图支持 Binance PAPI UM 与 Juplend 只读借贷账户
154
- - Juplend 只读,不支持订单和链上写操作;token 数量来自 USD / oracle price 反算
162
+ - Juplend 只读,不支持订单和链上写操作;仓位数量来自 `@jup-ag/lend-read` 原生 position 数据
155
163
  - Funding Rate 仅支持 Binance 永续合约,来自 mark price websocket;不支持现货和交割合约
156
164
  - `createOrder()` 只支持 `limit` / `market`;条件单、改单、账户级全撤不支持
157
165
  - 双向持仓账户下单时必须显式传 `positionSide`
@@ -209,14 +217,15 @@ bun run test:live:order:soak
209
217
 
210
218
  - `market`:`loadMarkets()`、`subscribeL1Book()`、`subscribeFundingRate()`、`getL1Book()` / `getL1Books()`、`getFundingRate()` / `getFundingRates()`、对应事件流和可选断线重连(`--disconnect-target funding` 可单独验证资金费率重连)
211
219
  - `account`:Binance PAPI UM 账户 bootstrap、余额/仓位/风险投影、private stream 更新和可选重连
212
- - `juplend`:Jupiter Portfolio API / vault 元数据连通性、lending balance facet、账户级 `riskRatio`、可选 `--position-id` 过滤单个 NFT position
220
+ - `juplend`:`@jup-ag/lend-read` + Jup Tokens/Price API 连通性、lending balance facet、账户级 `riskRatio`、支持 `--wallet-address` 聚合或 `--vault-id + --position-id` 单仓直读
213
221
  - `order`:open orders bootstrap、`subscribeOrders()`、订单事件投影和可选重连
214
222
 
215
223
  Juplend live smoke 示例:
216
224
 
217
225
  ```bash
218
- JUPITER_API_KEY=... JUPLEND_WALLET_ADDRESS=<wallet> bun run test:live:juplend -- --show-amounts
219
- JUPITER_API_KEY=... bun run test:live:juplend -- --account-id jup-loop-a --wallet-address <wallet> --position-id <nftId> --show-amounts
226
+ SOL_HELIUS_RPC=... JUPLEND_WALLET_ADDRESS=<wallet> bun run test:live:juplend -- --show-amounts
227
+ bun run test:live:juplend -- --account-id jup-loop-a --wallet-address <wallet> --position-id <nftId> --rpc-url <rpc> --show-amounts
228
+ bun run test:live:juplend -- --account-id jup-loop-a --vault-id <vaultId> --position-id <nftId> --rpc-url <rpc> --show-amounts
220
229
  ```
221
230
 
222
231
  ### 发布流程
package/docs/api.md CHANGED
@@ -64,7 +64,7 @@ for await (const event of client.market.events.l1BookUpdates({
64
64
  await client.stop();
65
65
  ```
66
66
 
67
- 同一个 client 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户。`account.binance.riskPollIntervalMs` 只配置 Binance 风险/仓位校准间隔;`account.juplend.pollIntervalMs` 只配置 Juplend polling 间隔,不会把 client 限定为某个 venue:
67
+ 同一个 client 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户。`account.binance.riskPollIntervalMs` 只配置 Binance 风险/仓位校准间隔;`account.juplend.pollIntervalMs` / `rpcUrl` / `jupApiKey` 只配置 Juplend polling、RPC Jup API,不会把 client 限定为某个 venue:
68
68
 
69
69
  ```ts
70
70
  const client = createClient({
@@ -74,6 +74,8 @@ const client = createClient({
74
74
  },
75
75
  juplend: {
76
76
  pollIntervalMs: 30_000,
77
+ rpcUrl: process.env.SOL_HELIUS_RPC,
78
+ jupApiKey: process.env.JUP_API,
77
79
  },
78
80
  },
79
81
  });
@@ -91,13 +93,21 @@ await client.registerAccount({
91
93
  await client.registerAccount({
92
94
  accountId: "jup-loop-a",
93
95
  venue: "juplend",
94
- credentials: { apiKey: process.env.JUPITER_API_KEY! },
95
96
  options: {
96
97
  walletAddress: "<solana-wallet-address>",
97
98
  positionId: "<optional-nft-position-id>",
98
99
  },
99
100
  });
100
101
 
102
+ await client.registerAccount({
103
+ accountId: "jup-loop-direct",
104
+ venue: "juplend",
105
+ options: {
106
+ vaultId: "<vault-id>",
107
+ positionId: "<nft-position-id>",
108
+ },
109
+ });
110
+
101
111
  await client.account.subscribeAccount({ accountId: "main-binance" });
102
112
  await client.account.subscribeAccount({ accountId: "jup-loop-a" });
103
113
 
@@ -216,6 +226,7 @@ const client = createClient({
216
226
  },
217
227
  juplend: {
218
228
  pollIntervalMs: 30_000,
229
+ rpcUrl: process.env.SOL_HELIUS_RPC,
219
230
  },
220
231
  },
221
232
  });
@@ -295,12 +306,20 @@ await client.registerAccount({
295
306
  await client.registerAccount({
296
307
  accountId: "jup-loop-a",
297
308
  venue: "juplend",
298
- credentials: { apiKey: jupiterApiKey },
299
309
  options: {
300
310
  walletAddress,
301
311
  positionId: "101", // 可选;不传则聚合该钱包全部 Juplend positions
302
312
  },
303
313
  });
314
+
315
+ await client.registerAccount({
316
+ accountId: "jup-loop-direct",
317
+ venue: "juplend",
318
+ options: {
319
+ vaultId: "1",
320
+ positionId: "101", // 直接读取单个 vault 内的 NFT position
321
+ },
322
+ });
304
323
  ```
305
324
 
306
325
  约束:
@@ -309,7 +328,8 @@ await client.registerAccount({
309
328
  - 凭证校验发生在 `subscribeAccount()` / `subscribeOrders()` 时,不是注册时
310
329
  - `updateAccountCredentials()` 可以在私有订阅活跃时调用,SDK 会按需重建私有链路
311
330
  - `removeAccount()` 比 `unsubscribeAccount()` 更彻底:账户配置、凭证、账户级缓存都会清理
312
- - Juplend 的 `accountId` 是自定义逻辑账户名;Solana 钱包地址必须放在 `options.walletAddress`,可用 `options.positionId` 把同钱包下单个 NFT position 映射为独立账户
331
+ - Juplend 的 `accountId` 是自定义逻辑账户名;可传 `options.walletAddress` 聚合钱包全部仓位,或传 `options.vaultId + options.positionId` 直接读取单个仓位
332
+ - Juplend 不要求 `credentials`;原生 read 默认读取 `SOL_HELIUS_RPC`,也可通过 `account.juplend.rpcUrl` 显式覆盖;token metadata / price 优先读取 `account.juplend.jupApiKey` 或环境变量 `JUP_API`
313
333
 
314
334
  ### 4.5 `getStatus()` / `getHealth()`
315
335
 
@@ -940,6 +960,8 @@ interface AccountRuntimeOptions {
940
960
  };
941
961
  juplend?: {
942
962
  pollIntervalMs?: number;
963
+ rpcUrl?: string;
964
+ jupApiKey?: string;
943
965
  };
944
966
  }
945
967
 
@@ -963,14 +985,17 @@ interface BinanceAccountOptions {
963
985
  recvWindow?: number;
964
986
  }
965
987
 
966
- interface JuplendAccountCredentials {
967
- apiKey: string;
968
- }
969
-
970
- interface JuplendAccountOptions {
971
- walletAddress: string;
972
- positionId?: string;
973
- }
988
+ type JuplendAccountOptions =
989
+ | {
990
+ walletAddress: string;
991
+ vaultId?: string;
992
+ positionId?: string;
993
+ }
994
+ | {
995
+ walletAddress?: string;
996
+ vaultId: string;
997
+ positionId: string;
998
+ };
974
999
 
975
1000
  type RegisterAccountInput =
976
1001
  | {
@@ -982,7 +1007,7 @@ type RegisterAccountInput =
982
1007
  | {
983
1008
  accountId: string;
984
1009
  venue: "juplend";
985
- credentials: JuplendAccountCredentials;
1010
+ credentials?: AccountCredentials;
986
1011
  options: JuplendAccountOptions;
987
1012
  };
988
1013
 
@@ -1444,8 +1469,8 @@ try {
1444
1469
  | `MARKET_STREAM_TIMEOUT` | market stream 首条消息超时 |
1445
1470
  | `ACCOUNT_ALREADY_EXISTS` | 重复注册同一个 `accountId` |
1446
1471
  | `ACCOUNT_NOT_FOUND` | `accountId` 未注册或已被移除 |
1447
- | `ACCOUNT_BOOTSTRAP_FAILED` | `subscribeAccount()` 过程中账户快照拉取失败,例如 Juplend HTTP/API 失败或缺 `options.walletAddress` |
1448
- | `CREDENTIALS_MISSING` | 私有订阅 / 下单缺必要凭证,例如 Binance 缺 `apiKey/secret` 或 Juplend 缺 `apiKey` |
1472
+ | `ACCOUNT_BOOTSTRAP_FAILED` | `subscribeAccount()` 过程中账户快照拉取失败,例如 Juplend HTTP/API 失败、缺 `options.walletAddress`,或 direct 模式下缺失/提供了无效的 `options.vaultId`、`options.positionId` |
1473
+ | `CREDENTIALS_MISSING` | 私有订阅 / 下单缺必要凭证,例如 Binance 缺 `apiKey/secret` |
1449
1474
  | `ORDER_BOOTSTRAP_FAILED` | `subscribeOrders()` 过程中 open orders 拉取失败 |
1450
1475
  | `ORDER_INPUT_INVALID` | 下单/撤单本地输入校验失败(如缺 price、缺 id) |
1451
1476
  | `ORDER_CREATE_FAILED` | 交易所拒单 / REST 报错 |
@@ -1458,7 +1483,7 @@ try {
1458
1483
  - **市场数据**:真实落地 Binance L1 Book(Spot + USDⓈ-M + COIN-M)和 Binance 永续 Funding Rate
1459
1484
  - **私有链路**:Binance PAPI UM 使用 listenKey/WebSocket;Juplend 使用 HTTP polling,只提供账户只读视图
1460
1485
  - **Juplend 写操作**:不支持 supply / borrow / repay / withdraw,不支持 `OrderManager` 下单撤单
1461
- - **Juplend 数量精度**:Portfolio API 提供 USD 聚合值,token 数量由 USD / vault oracle price 反算
1486
+ - **Juplend 数据源**:账户主数据来自 `@jup-ag/lend-read` 原生 position read;token metadata / spot price 优先来自 Jup 官方 `Tokens V2 + Price V3`,lite vault metadata 只做 fallback
1462
1487
  - **Funding Rate**:仅永续合约支持,来自 mark price websocket;不支持现货和交割合约
1463
1488
  - **下单类型**:`createOrder()` 仅支持 `limit` / `market`;条件单、改单不支持
1464
1489
  - **撤单范围**:`cancelAllOrders()` 必须传 `symbol`,不支持账户级全撤
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.3.0-beta.5",
3
+ "version": "0.3.0-beta.6",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,6 +53,8 @@
53
53
  "typescript": "^6.0.2"
54
54
  },
55
55
  "dependencies": {
56
+ "@jup-ag/lend-read": "^0.0.12",
57
+ "@solana/web3.js": "^1.98.4",
56
58
  "bignumber.js": "^11.0.0"
57
59
  }
58
60
  }
@@ -0,0 +1,150 @@
1
+ import { Client, DEFAULT_RPC_URL } from "@jup-ag/lend-read";
2
+ import { PublicKey } from "@solana/web3.js";
3
+
4
+ interface StringLike {
5
+ toString(): string;
6
+ }
7
+
8
+ export interface JuplendVaultLike {
9
+ constantViews: {
10
+ vaultId: number;
11
+ supplyToken: StringLike;
12
+ borrowToken: StringLike;
13
+ };
14
+ configs: {
15
+ liquidationThreshold: StringLike;
16
+ };
17
+ exchangePricesAndRates: {
18
+ supplyRateVault: StringLike;
19
+ borrowRateVault: StringLike;
20
+ };
21
+ }
22
+
23
+ export interface JuplendPositionLike {
24
+ nftId: number;
25
+ supply: StringLike;
26
+ borrow: StringLike;
27
+ dustBorrow: StringLike;
28
+ vault: JuplendVaultLike;
29
+ }
30
+
31
+ export interface JuplendVaultReader {
32
+ getAllUserPositions(user: PublicKey): Promise<JuplendPositionLike[]>;
33
+ getPositionByVaultId(
34
+ vaultId: number,
35
+ nftId: number,
36
+ ): Promise<JuplendPositionLike>;
37
+ }
38
+
39
+ export interface JuplendReadSdkLike {
40
+ createVaultReader(rpcUrl: string): JuplendVaultReader;
41
+ }
42
+
43
+ export interface JuplendReadPosition {
44
+ nftId: string;
45
+ vaultId: string;
46
+ supplyAmount: string;
47
+ borrowAmount: string;
48
+ dustBorrowAmount: string;
49
+ liquidationThresholdRaw: string;
50
+ supplyRateRaw: string;
51
+ borrowRateRaw: string;
52
+ supplyMintAddress: string;
53
+ borrowMintAddress: string;
54
+ }
55
+
56
+ const defaultSdk: JuplendReadSdkLike = {
57
+ createVaultReader(rpcUrl: string): JuplendVaultReader {
58
+ return new Client(rpcUrl).vault;
59
+ },
60
+ };
61
+
62
+ let activeSdk: JuplendReadSdkLike = defaultSdk;
63
+ let readerCache = new Map<string, JuplendVaultReader>();
64
+
65
+ export function getJuplendRpcUrl(explicitRpcUrl?: string): string {
66
+ return explicitRpcUrl || process.env.SOL_HELIUS_RPC || DEFAULT_RPC_URL;
67
+ }
68
+
69
+ function getVaultReader(rpcUrl: string): JuplendVaultReader {
70
+ const cached = readerCache.get(rpcUrl);
71
+ if (cached) {
72
+ return cached;
73
+ }
74
+
75
+ const created = activeSdk.createVaultReader(rpcUrl);
76
+ readerCache.set(rpcUrl, created);
77
+ return created;
78
+ }
79
+
80
+ function toJuplendId(value: string, field: "vaultId" | "positionId"): number {
81
+ const parsed = Number(value);
82
+ if (!Number.isInteger(parsed) || parsed <= 0) {
83
+ throw new Error(`Invalid Juplend ${field}: ${value}`);
84
+ }
85
+
86
+ return parsed;
87
+ }
88
+
89
+ function mapReadPosition(position: JuplendPositionLike): JuplendReadPosition {
90
+ const vaultId = position.vault.constantViews.vaultId;
91
+
92
+ return {
93
+ nftId: `${position.nftId}`,
94
+ vaultId: `${vaultId}`,
95
+ supplyAmount: position.supply.toString(),
96
+ borrowAmount: position.borrow.toString(),
97
+ dustBorrowAmount: position.dustBorrow.toString(),
98
+ liquidationThresholdRaw:
99
+ position.vault.configs.liquidationThreshold.toString(),
100
+ supplyRateRaw:
101
+ position.vault.exchangePricesAndRates.supplyRateVault.toString(),
102
+ borrowRateRaw:
103
+ position.vault.exchangePricesAndRates.borrowRateVault.toString(),
104
+ supplyMintAddress: position.vault.constantViews.supplyToken.toString(),
105
+ borrowMintAddress: position.vault.constantViews.borrowToken.toString(),
106
+ };
107
+ }
108
+
109
+ export async function readJuplendPositions(input: {
110
+ walletAddress?: string;
111
+ vaultId?: string;
112
+ positionId?: string;
113
+ explicitRpcUrl?: string;
114
+ }): Promise<{
115
+ rpcUrl: string;
116
+ positions: JuplendReadPosition[];
117
+ }> {
118
+ const rpcUrl = getJuplendRpcUrl(input.explicitRpcUrl);
119
+ const reader = getVaultReader(rpcUrl);
120
+ const rawPositions =
121
+ input.vaultId && input.positionId
122
+ ? [
123
+ await reader.getPositionByVaultId(
124
+ toJuplendId(input.vaultId, "vaultId"),
125
+ toJuplendId(input.positionId, "positionId"),
126
+ ),
127
+ ]
128
+ : input.walletAddress
129
+ ? await reader.getAllUserPositions(new PublicKey(input.walletAddress))
130
+ : (() => {
131
+ throw new Error(
132
+ "Juplend read requires options.walletAddress or options.vaultId + options.positionId",
133
+ );
134
+ })();
135
+
136
+ return {
137
+ rpcUrl,
138
+ positions: rawPositions.map((position) => mapReadPosition(position)),
139
+ };
140
+ }
141
+
142
+ export function setJuplendReadSdkForTests(sdk: JuplendReadSdkLike): void {
143
+ activeSdk = sdk;
144
+ readerCache = new Map<string, JuplendVaultReader>();
145
+ }
146
+
147
+ export function resetJuplendReadSdkForTests(): void {
148
+ activeSdk = defaultSdk;
149
+ readerCache = new Map<string, JuplendVaultReader>();
150
+ }
@@ -18,42 +18,29 @@ import type {
18
18
  RawRiskUpdate,
19
19
  StreamHandle,
20
20
  } from "../types.ts";
21
+ import { readJuplendPositions } from "./lend-read.ts";
21
22
 
22
- interface JuplendPortfolioResponse {
23
- elements?: JuplendPortfolioElement[];
24
- }
25
-
26
- interface JuplendPortfolioElement {
27
- data?: {
28
- link?: string;
29
- suppliedValue?: number | string;
30
- borrowedValue?: number | string;
31
- value?: number | string;
32
- };
33
- }
34
-
35
- interface JuplendVaultResponse {
36
- data?: JuplendVault[];
23
+ interface JuplendTokenMetadata {
24
+ address?: string;
25
+ id?: string;
26
+ symbol?: string;
27
+ uiSymbol?: string;
28
+ decimals?: number | string;
29
+ price?: number | string;
30
+ usdPrice?: number | string;
31
+ oraclePrice?: number | string;
37
32
  }
38
33
 
39
- interface JuplendVault {
34
+ interface JuplendVaultMetadata {
40
35
  id?: number | string;
41
36
  vaultId?: number | string;
42
- supplyToken?: JuplendToken;
43
- borrowToken?: JuplendToken;
37
+ supplyToken?: JuplendTokenMetadata;
38
+ borrowToken?: JuplendTokenMetadata;
44
39
  liquidationThreshold?: number | string;
45
- loanToValue?: number | string;
46
40
  supplyRate?: number | string;
47
41
  borrowRate?: number | string;
48
42
  }
49
43
 
50
- interface JuplendToken {
51
- symbol?: string;
52
- asset?: string;
53
- oraclePrice?: number | string;
54
- price?: number | string;
55
- }
56
-
57
44
  interface JuplendMappedAccount {
58
45
  balances: RawBalanceUpdate[];
59
46
  risk?: RawRiskUpdate;
@@ -68,88 +55,125 @@ interface BalanceAccumulator {
68
55
  }
69
56
 
70
57
  interface JuplendAccountOptions {
71
- walletAddress: string;
58
+ walletAddress?: string;
59
+ vaultId?: string;
72
60
  positionId?: string;
73
61
  }
74
62
 
75
- const PORTFOLIO_BASE_URL = "https://api.jup.ag/portfolio/v1";
76
- const VAULTS_URL = "https://lite-api.jup.ag/lend/v1/borrow/vaults";
77
- const DEFAULT_POLL_INTERVAL_MS = 30_000;
78
- const VAULT_CACHE_TTL_MS = 60 * 60 * 1_000;
79
- const LINK_PATTERN = /\/borrow\/([^/]+)\/nfts\/([^/?#]+)/;
63
+ interface JuplendPriceApiEntry {
64
+ usdPrice?: number | string;
65
+ price?: number | string;
66
+ decimals?: number | string;
67
+ }
80
68
 
81
- let vaultCache:
82
- | {
83
- loadedAt: number;
84
- vaults: Map<string, JuplendVault>;
85
- }
86
- | undefined;
87
- let vaultCachePromise: Promise<Map<string, JuplendVault>> | undefined;
69
+ interface JuplendTokenSearchEntry {
70
+ id?: string;
71
+ address?: string;
72
+ symbol?: string;
73
+ name?: string;
74
+ decimals?: number | string;
75
+ usdPrice?: number | string;
76
+ }
88
77
 
89
- function requireApiKey(credentials: AccountCredentials): string {
90
- if (!credentials.apiKey) {
91
- throw new Error("credentials.apiKey required");
92
- }
78
+ const JUP_API_BASE_URL = "https://api.jup.ag";
79
+ const JUP_LITE_API_BASE_URL = "https://lite-api.jup.ag";
80
+ const TOKENS_SEARCH_PATH = "/tokens/v2/search";
81
+ const PRICE_V3_PATH = "/price/v3";
82
+ const LEND_VAULTS_PATH = "/lend/v1/borrow/vaults";
83
+ const DEFAULT_POLL_INTERVAL_MS = 30_000;
84
+ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
85
+ // lend-read returns exchange-price-adjusted amounts on a fixed 1e9 scale,
86
+ // not mint-atomic token amounts.
87
+ const POSITION_AMOUNT_SCALE_DECIMALS = 9;
88
+ const VAULT_CACHE_TTL_MS = 60 * 60 * 1_000;
93
89
 
94
- return credentials.apiKey;
90
+ interface JuplendVaultEnrichmentCacheEntry {
91
+ loadedAt: number;
92
+ vaults: Map<string, JuplendVaultMetadata>;
93
+ enriched: boolean;
95
94
  }
96
95
 
96
+ let enrichmentCache = new Map<string, JuplendVaultEnrichmentCacheEntry>();
97
+ let enrichmentCachePromise = new Map<
98
+ string,
99
+ Promise<Map<string, JuplendVaultMetadata>>
100
+ >();
101
+
97
102
  function getJuplendAccountOptions(
98
103
  accountOptions?: Record<string, unknown>,
99
104
  ): JuplendAccountOptions {
100
105
  const walletAddress = accountOptions?.walletAddress;
101
- if (typeof walletAddress !== "string" || !walletAddress) {
102
- throw new Error("options.walletAddress required");
106
+ if (walletAddress !== undefined && typeof walletAddress !== "string") {
107
+ throw new Error("options.walletAddress must be a string");
103
108
  }
104
109
 
105
- const positionId = accountOptions.positionId;
110
+ const vaultId = accountOptions?.vaultId;
111
+ if (vaultId !== undefined && typeof vaultId !== "string") {
112
+ throw new Error("options.vaultId must be a string");
113
+ }
114
+ const positionId = accountOptions?.positionId;
106
115
  if (positionId !== undefined && typeof positionId !== "string") {
107
116
  throw new Error("options.positionId must be a string");
108
117
  }
109
118
 
119
+ const hasWalletAddress = Boolean(walletAddress);
120
+ const hasDirectPosition = Boolean(vaultId && positionId);
121
+ if (!hasWalletAddress && !hasDirectPosition) {
122
+ throw new Error(
123
+ "options.walletAddress or options.vaultId + options.positionId required",
124
+ );
125
+ }
126
+
110
127
  return {
111
- walletAddress,
128
+ walletAddress: walletAddress || undefined,
129
+ vaultId: vaultId || undefined,
112
130
  positionId: positionId || undefined,
113
131
  };
114
132
  }
115
133
 
116
- function toBigNumber(value: number | string | undefined): BigNumber {
117
- return value === undefined ? new BigNumber(0) : new BigNumber(value);
134
+ function toBigNumber(
135
+ value: BigNumber.Value | undefined,
136
+ fallback = new BigNumber(0),
137
+ ): BigNumber {
138
+ return value === undefined ? fallback : new BigNumber(value);
118
139
  }
119
140
 
120
- function normalizeThreshold(value: number | string | undefined): BigNumber {
141
+ function normalizeThreshold(value: BigNumber.Value | undefined): BigNumber {
121
142
  const threshold = toBigNumber(value);
122
143
  return threshold.gt(1) ? threshold.dividedBy(1000) : threshold;
123
144
  }
124
145
 
125
- function tokenAsset(token: JuplendToken | undefined): string | undefined {
126
- return token?.symbol ?? token?.asset;
127
- }
128
-
129
- function tokenPrice(token: JuplendToken | undefined): BigNumber | undefined {
130
- const price = toBigNumber(token?.oraclePrice ?? token?.price);
131
- return price.gt(0) ? price : undefined;
132
- }
133
-
134
- function extractPositionLink(
135
- link: string | undefined,
136
- ): { vaultId: string; positionId: string } | undefined {
137
- if (!link) {
146
+ function normalizeRate(
147
+ value: BigNumber.Value | undefined,
148
+ ): BigNumber | undefined {
149
+ if (value === undefined) {
138
150
  return undefined;
139
151
  }
140
152
 
141
- const match = LINK_PATTERN.exec(link);
142
- if (!match?.[1] || !match[2]) {
153
+ const rate = new BigNumber(value);
154
+ if (!rate.isFinite()) {
143
155
  return undefined;
144
156
  }
145
157
 
146
- return {
147
- vaultId: match[1],
148
- positionId: match[2],
149
- };
158
+ return rate.gt(1) ? rate.dividedBy(10_000) : rate;
150
159
  }
151
160
 
152
- function getVaultId(vault: JuplendVault): string | undefined {
161
+ function tokenAsset(
162
+ token: JuplendTokenMetadata | undefined,
163
+ ): string | undefined {
164
+ return token?.uiSymbol ?? token?.symbol;
165
+ }
166
+
167
+ function tokenPrice(
168
+ token: JuplendTokenMetadata | undefined,
169
+ ): BigNumber | undefined {
170
+ const price = toBigNumber(
171
+ token?.usdPrice ?? token?.price ?? token?.oraclePrice,
172
+ );
173
+ return price.gt(0) ? price : undefined;
174
+ }
175
+
176
+ function getVaultId(vault: JuplendVaultMetadata): string | undefined {
153
177
  const id = vault.id ?? vault.vaultId;
154
178
  return id === undefined ? undefined : `${id}`;
155
179
  }
@@ -237,119 +261,361 @@ function buildRisk(input: {
237
261
  }
238
262
 
239
263
  async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
240
- const response = await fetch(url, init);
241
- if (!response.ok) {
242
- throw new Error(`Juplend HTTP ${response.status}: ${response.statusText}`);
264
+ const controller = new AbortController();
265
+ const upstreamSignal = init?.signal;
266
+ let timedOut = false;
267
+ const onUpstreamAbort = () => {
268
+ controller.abort();
269
+ };
270
+
271
+ if (upstreamSignal?.aborted) {
272
+ controller.abort();
273
+ } else if (upstreamSignal) {
274
+ upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
243
275
  }
244
276
 
245
- return (await response.json()) as T;
277
+ const timeout = setTimeout(() => {
278
+ timedOut = true;
279
+ controller.abort();
280
+ }, DEFAULT_HTTP_TIMEOUT_MS);
281
+
282
+ try {
283
+ const response = await fetch(url, {
284
+ ...init,
285
+ signal: controller.signal,
286
+ });
287
+ if (!response.ok) {
288
+ throw new Error(
289
+ `Juplend HTTP ${response.status}: ${response.statusText}`,
290
+ );
291
+ }
292
+
293
+ return (await response.json()) as T;
294
+ } catch (error) {
295
+ if (error instanceof Error && error.name === "AbortError") {
296
+ throw new Error(
297
+ timedOut
298
+ ? `Juplend fetch timeout after ${DEFAULT_HTTP_TIMEOUT_MS}ms`
299
+ : "Juplend fetch aborted",
300
+ );
301
+ }
302
+ throw error;
303
+ } finally {
304
+ clearTimeout(timeout);
305
+ if (upstreamSignal) {
306
+ upstreamSignal.removeEventListener("abort", onUpstreamAbort);
307
+ }
308
+ }
246
309
  }
247
310
 
248
- async function loadVaults(now: number): Promise<Map<string, JuplendVault>> {
249
- if (vaultCache && now - vaultCache.loadedAt < VAULT_CACHE_TTL_MS) {
250
- return vaultCache.vaults;
311
+ function getJupApiKey(explicitApiKey?: string): string | undefined {
312
+ return explicitApiKey || process.env.JUP_API || undefined;
313
+ }
314
+
315
+ function getEnrichmentCacheKey(apiKey?: string): string {
316
+ return apiKey || "__no_jup_api_key__";
317
+ }
318
+
319
+ function buildApiHeaders(apiKey?: string): Record<string, string> | undefined {
320
+ return apiKey ? { "x-api-key": apiKey } : undefined;
321
+ }
322
+
323
+ function withBaseUrl(baseUrl: string, path: string): string {
324
+ return new URL(path, `${baseUrl}/`).toString();
325
+ }
326
+
327
+ async function loadVaultMetadataFromLiteApi(
328
+ apiKey?: string,
329
+ ): Promise<Map<string, JuplendVaultMetadata>> {
330
+ const response = await readJson<
331
+ JuplendVaultMetadata[] | { data?: JuplendVaultMetadata[] }
332
+ >(withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH), {
333
+ headers: buildApiHeaders(apiKey),
334
+ });
335
+ const rawVaults = Array.isArray(response) ? response : response.data;
336
+ const vaults = new Map<string, JuplendVaultMetadata>();
337
+
338
+ for (const vault of rawVaults ?? []) {
339
+ const id = getVaultId(vault);
340
+ if (id) {
341
+ vaults.set(id, vault);
342
+ }
251
343
  }
252
344
 
253
- if (!vaultCachePromise) {
254
- vaultCachePromise = readJson<JuplendVaultResponse | JuplendVault[]>(
255
- VAULTS_URL,
256
- )
257
- .then((response) => {
258
- const rawVaults = Array.isArray(response) ? response : response.data;
259
- const vaults = new Map<string, JuplendVault>();
260
- for (const vault of rawVaults ?? []) {
261
- const id = getVaultId(vault);
262
- if (id) {
263
- vaults.set(id, vault);
264
- }
265
- }
266
- vaultCache = { loadedAt: now, vaults };
267
- return vaults;
268
- })
269
- .finally(() => {
270
- vaultCachePromise = undefined;
271
- });
345
+ return vaults;
346
+ }
347
+
348
+ async function loadTokenSearchMap(
349
+ mintAddresses: string[],
350
+ apiKey?: string,
351
+ ): Promise<Map<string, JuplendTokenMetadata>> {
352
+ if (mintAddresses.length === 0) {
353
+ return new Map();
272
354
  }
273
355
 
274
- try {
275
- return await vaultCachePromise;
276
- } catch (error) {
277
- if (vaultCache) {
278
- return vaultCache.vaults;
356
+ const query = encodeURIComponent(mintAddresses.join(","));
357
+ const response = await readJson<JuplendTokenSearchEntry[]>(
358
+ `${withBaseUrl(JUP_API_BASE_URL, TOKENS_SEARCH_PATH)}?query=${query}`,
359
+ {
360
+ headers: buildApiHeaders(apiKey),
361
+ },
362
+ );
363
+
364
+ const tokens = new Map<string, JuplendTokenMetadata>();
365
+ for (const token of response ?? []) {
366
+ const mint = token.id ?? token.address;
367
+ if (!mint) {
368
+ continue;
279
369
  }
280
- throw error;
370
+
371
+ tokens.set(mint, {
372
+ address: mint,
373
+ id: mint,
374
+ symbol: token.symbol,
375
+ uiSymbol: token.symbol,
376
+ decimals: token.decimals,
377
+ usdPrice: token.usdPrice,
378
+ oraclePrice: token.usdPrice,
379
+ });
281
380
  }
381
+
382
+ return tokens;
282
383
  }
283
384
 
284
- async function loadPortfolio(
285
- walletAddress: string,
286
- apiKey: string,
287
- ): Promise<JuplendPortfolioResponse> {
288
- return readJson<JuplendPortfolioResponse>(
289
- `${PORTFOLIO_BASE_URL}/positions/${walletAddress}?platforms=jupiter-exchange`,
385
+ async function loadPriceMap(
386
+ mintAddresses: string[],
387
+ apiKey?: string,
388
+ ): Promise<Map<string, JuplendPriceApiEntry>> {
389
+ if (mintAddresses.length === 0) {
390
+ return new Map();
391
+ }
392
+
393
+ const ids = encodeURIComponent(mintAddresses.join(","));
394
+ const response = await readJson<Record<string, JuplendPriceApiEntry>>(
395
+ `${withBaseUrl(JUP_API_BASE_URL, PRICE_V3_PATH)}?ids=${ids}`,
290
396
  {
291
- headers: {
292
- "X-API-KEY": apiKey,
293
- },
397
+ headers: buildApiHeaders(apiKey),
294
398
  },
295
399
  );
400
+
401
+ return new Map(Object.entries(response ?? {}));
296
402
  }
297
403
 
298
- function mapAccount(
299
- portfolio: JuplendPortfolioResponse,
300
- vaults: Map<string, JuplendVault>,
404
+ function mergeTokenMetadata(
405
+ baseToken: JuplendTokenMetadata | undefined,
406
+ searchedToken: JuplendTokenMetadata | undefined,
407
+ pricedToken: JuplendPriceApiEntry | undefined,
408
+ ): JuplendTokenMetadata | undefined {
409
+ if (!baseToken && !searchedToken && !pricedToken) {
410
+ return undefined;
411
+ }
412
+
413
+ return {
414
+ ...baseToken,
415
+ ...searchedToken,
416
+ price:
417
+ pricedToken?.usdPrice ??
418
+ pricedToken?.price ??
419
+ searchedToken?.usdPrice ??
420
+ baseToken?.usdPrice ??
421
+ baseToken?.price ??
422
+ baseToken?.oraclePrice,
423
+ usdPrice:
424
+ pricedToken?.usdPrice ??
425
+ pricedToken?.price ??
426
+ searchedToken?.usdPrice ??
427
+ baseToken?.usdPrice ??
428
+ baseToken?.price ??
429
+ baseToken?.oraclePrice,
430
+ oraclePrice: baseToken?.oraclePrice,
431
+ decimals:
432
+ searchedToken?.decimals ?? pricedToken?.decimals ?? baseToken?.decimals,
433
+ };
434
+ }
435
+
436
+ async function enrichVaultsWithJupApi(input: {
437
+ apiKey?: string;
438
+ baseVaults: Map<string, JuplendVaultMetadata>;
439
+ }): Promise<Map<string, JuplendVaultMetadata>> {
440
+ const mintAddresses = new Set<string>();
441
+ for (const vault of input.baseVaults.values()) {
442
+ const supplyMint = vault.supplyToken?.address;
443
+ const borrowMint = vault.borrowToken?.address;
444
+ if (supplyMint) {
445
+ mintAddresses.add(supplyMint);
446
+ }
447
+ if (borrowMint) {
448
+ mintAddresses.add(borrowMint);
449
+ }
450
+ }
451
+
452
+ const [tokenMap, priceMap] = await Promise.all([
453
+ loadTokenSearchMap([...mintAddresses], input.apiKey),
454
+ loadPriceMap([...mintAddresses], input.apiKey),
455
+ ]);
456
+
457
+ const enriched = new Map<string, JuplendVaultMetadata>();
458
+ for (const [vaultId, vault] of input.baseVaults.entries()) {
459
+ const supplyMint = vault.supplyToken?.address;
460
+ const borrowMint = vault.borrowToken?.address;
461
+
462
+ enriched.set(vaultId, {
463
+ ...vault,
464
+ supplyToken: mergeTokenMetadata(
465
+ vault.supplyToken,
466
+ supplyMint ? tokenMap.get(supplyMint) : undefined,
467
+ supplyMint ? priceMap.get(supplyMint) : undefined,
468
+ ),
469
+ borrowToken: mergeTokenMetadata(
470
+ vault.borrowToken,
471
+ borrowMint ? tokenMap.get(borrowMint) : undefined,
472
+ borrowMint ? priceMap.get(borrowMint) : undefined,
473
+ ),
474
+ });
475
+ }
476
+
477
+ return enriched;
478
+ }
479
+
480
+ async function loadVaults(
481
+ now: number,
482
+ apiKey?: string,
483
+ ): Promise<Map<string, JuplendVaultMetadata>> {
484
+ const cacheKey = getEnrichmentCacheKey(apiKey);
485
+ const cached = enrichmentCache.get(cacheKey);
486
+ const cacheFresh =
487
+ cached !== undefined && now - cached.loadedAt < VAULT_CACHE_TTL_MS;
488
+ if (cacheFresh && (cached.enriched || !apiKey)) {
489
+ return cached.vaults;
490
+ }
491
+
492
+ const inflight = enrichmentCachePromise.get(cacheKey);
493
+ if (!inflight) {
494
+ const nextPromise = (async () => {
495
+ const baseVaults = await loadVaultMetadataFromLiteApi(apiKey);
496
+ if (!apiKey) {
497
+ enrichmentCache.set(cacheKey, {
498
+ loadedAt: now,
499
+ vaults: baseVaults,
500
+ enriched: false,
501
+ });
502
+ return baseVaults;
503
+ }
504
+
505
+ try {
506
+ const enrichedVaults = await enrichVaultsWithJupApi({
507
+ apiKey,
508
+ baseVaults,
509
+ });
510
+ enrichmentCache.set(cacheKey, {
511
+ loadedAt: now,
512
+ vaults: enrichedVaults,
513
+ enriched: true,
514
+ });
515
+ return enrichedVaults;
516
+ } catch {
517
+ return baseVaults;
518
+ }
519
+ })().finally(() => {
520
+ enrichmentCachePromise.delete(cacheKey);
521
+ });
522
+
523
+ enrichmentCachePromise.set(cacheKey, nextPromise);
524
+ }
525
+
526
+ try {
527
+ return await (enrichmentCachePromise.get(cacheKey) as Promise<
528
+ Map<string, JuplendVaultMetadata>
529
+ >);
530
+ } catch (error) {
531
+ const fallbackCached = enrichmentCache.get(cacheKey);
532
+ if (fallbackCached) {
533
+ return fallbackCached.vaults;
534
+ }
535
+ throw error;
536
+ }
537
+ }
538
+
539
+ function dividePositionAmount(value: BigNumber): BigNumber {
540
+ return value.dividedBy(new BigNumber(10).pow(POSITION_AMOUNT_SCALE_DECIMALS));
541
+ }
542
+
543
+ async function mapAccount(
544
+ accountOptions: JuplendAccountOptions,
301
545
  receivedAt: number,
302
- positionId?: string,
303
- ): JuplendMappedAccount {
546
+ rpcUrl: string | undefined,
547
+ jupApiKey: string | undefined,
548
+ ): Promise<JuplendMappedAccount> {
549
+ const [vaults, positionResult] = await Promise.all([
550
+ loadVaults(receivedAt, jupApiKey),
551
+ readJuplendPositions({
552
+ walletAddress: accountOptions.walletAddress,
553
+ vaultId: accountOptions.vaultId,
554
+ positionId: accountOptions.positionId,
555
+ explicitRpcUrl: rpcUrl,
556
+ }),
557
+ ]);
558
+
304
559
  const balances = new Map<string, BalanceAccumulator>();
305
560
  let totalCollateralUsd = new BigNumber(0);
306
561
  let totalDebtUsd = new BigNumber(0);
307
562
  let weightedLiquidationValueUsd = new BigNumber(0);
308
563
 
309
- for (const element of portfolio.elements ?? []) {
310
- const positionLink = extractPositionLink(element.data?.link);
311
- if (!positionLink) {
312
- continue;
313
- }
314
-
315
- if (positionId && positionLink.positionId !== positionId) {
564
+ for (const position of positionResult.positions) {
565
+ if (
566
+ accountOptions.walletAddress &&
567
+ accountOptions.positionId &&
568
+ position.nftId !== accountOptions.positionId
569
+ ) {
316
570
  continue;
317
571
  }
318
572
 
319
- const vault = vaults.get(positionLink.vaultId);
320
- if (!vault) {
321
- continue;
322
- }
573
+ const vault = vaults.get(position.vaultId);
574
+ const suppliedQuantity = dividePositionAmount(
575
+ toBigNumber(position.supplyAmount),
576
+ );
577
+ const borrowedQuantity = dividePositionAmount(
578
+ toBigNumber(position.borrowAmount),
579
+ );
323
580
 
324
- const suppliedValue = toBigNumber(element.data?.suppliedValue);
325
- const borrowedValue = toBigNumber(element.data?.borrowedValue);
326
581
  const liquidationThreshold = normalizeThreshold(
327
- vault.liquidationThreshold ?? vault.loanToValue,
328
- );
329
- totalCollateralUsd = totalCollateralUsd.plus(suppliedValue);
330
- totalDebtUsd = totalDebtUsd.plus(borrowedValue);
331
- weightedLiquidationValueUsd = weightedLiquidationValueUsd.plus(
332
- suppliedValue.multipliedBy(liquidationThreshold),
582
+ position.liquidationThresholdRaw ?? vault?.liquidationThreshold,
333
583
  );
334
584
 
335
- const supplyAsset = tokenAsset(vault.supplyToken);
336
- const supplyPrice = tokenPrice(vault.supplyToken);
337
- if (supplyAsset && supplyPrice) {
585
+ const supplyAsset =
586
+ tokenAsset(vault?.supplyToken) ?? vault?.supplyToken?.address;
587
+ if (supplyAsset) {
338
588
  const accumulator = setAccumulator(balances, supplyAsset);
339
- accumulator.supplied = accumulator.supplied.plus(
340
- suppliedValue.dividedBy(supplyPrice),
341
- );
342
- accumulator.supplyAPY = toBigNumber(vault.supplyRate);
589
+ accumulator.supplied = accumulator.supplied.plus(suppliedQuantity);
590
+ accumulator.supplyAPY =
591
+ normalizeRate(vault?.supplyRate ?? position.supplyRateRaw) ??
592
+ accumulator.supplyAPY;
343
593
  }
344
594
 
345
- const borrowAsset = tokenAsset(vault.borrowToken);
346
- const borrowPrice = tokenPrice(vault.borrowToken);
347
- if (borrowAsset && borrowPrice) {
595
+ const borrowAsset =
596
+ tokenAsset(vault?.borrowToken) ?? vault?.borrowToken?.address;
597
+ if (borrowAsset) {
348
598
  const accumulator = setAccumulator(balances, borrowAsset);
349
- accumulator.borrowed = accumulator.borrowed.plus(
350
- borrowedValue.dividedBy(borrowPrice),
599
+ accumulator.borrowed = accumulator.borrowed.plus(borrowedQuantity);
600
+ accumulator.borrowAPY =
601
+ normalizeRate(vault?.borrowRate ?? position.borrowRateRaw) ??
602
+ accumulator.borrowAPY;
603
+ }
604
+
605
+ const supplyPrice = tokenPrice(vault?.supplyToken);
606
+ if (supplyPrice) {
607
+ const collateralUsd = suppliedQuantity.multipliedBy(supplyPrice);
608
+ totalCollateralUsd = totalCollateralUsd.plus(collateralUsd);
609
+ weightedLiquidationValueUsd = weightedLiquidationValueUsd.plus(
610
+ collateralUsd.multipliedBy(liquidationThreshold),
611
+ );
612
+ }
613
+
614
+ const borrowPrice = tokenPrice(vault?.borrowToken);
615
+ if (borrowPrice) {
616
+ totalDebtUsd = totalDebtUsd.plus(
617
+ borrowedQuantity.multipliedBy(borrowPrice),
351
618
  );
352
- accumulator.borrowAPY = toBigNumber(vault.borrowRate);
353
619
  }
354
620
  }
355
621
 
@@ -379,7 +645,7 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
379
645
  positions: "unsupported",
380
646
  risk: "supported",
381
647
  lending: "supported",
382
- credentialsRequired: true,
648
+ credentialsRequired: false,
383
649
  };
384
650
  readonly orderCapabilities: VenueOrderCapabilities = {
385
651
  supported: false,
@@ -397,22 +663,22 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
397
663
  reason: "read_only",
398
664
  };
399
665
 
666
+ constructor(
667
+ private readonly rpcUrl?: string,
668
+ private readonly jupApiKey?: string,
669
+ ) {}
670
+
400
671
  async bootstrapAccount(
401
- credentials: AccountCredentials,
672
+ _credentials: AccountCredentials,
402
673
  accountOptions?: Record<string, unknown>,
403
674
  ): Promise<RawAccountBootstrap> {
404
675
  const receivedAt = Date.now();
405
- const apiKey = requireApiKey(credentials);
406
676
  const juplendOptions = getJuplendAccountOptions(accountOptions);
407
- const [portfolio, vaults] = await Promise.all([
408
- loadPortfolio(juplendOptions.walletAddress, apiKey),
409
- loadVaults(receivedAt),
410
- ]);
411
- const mapped = mapAccount(
412
- portfolio,
413
- vaults,
677
+ const mapped = await mapAccount(
678
+ juplendOptions,
414
679
  receivedAt,
415
- juplendOptions.positionId,
680
+ this.rpcUrl,
681
+ getJupApiKey(this.jupApiKey),
416
682
  );
417
683
 
418
684
  return {
@@ -477,6 +743,7 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
477
743
  if (closed) {
478
744
  return;
479
745
  }
746
+
480
747
  callbacks.onAccountSnapshot(bootstrap);
481
748
  } catch (error) {
482
749
  callbacks.onError(
@@ -513,6 +780,9 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
513
780
  }
514
781
 
515
782
  export function resetJuplendVaultCacheForTests(): void {
516
- vaultCache = undefined;
517
- vaultCachePromise = undefined;
783
+ enrichmentCache = new Map<string, JuplendVaultEnrichmentCacheEntry>();
784
+ enrichmentCachePromise = new Map<
785
+ string,
786
+ Promise<Map<string, JuplendVaultMetadata>>
787
+ >();
518
788
  }
@@ -108,7 +108,7 @@ export function hasPrivateCredentials(
108
108
  venue?: Venue,
109
109
  ): boolean {
110
110
  if (venue === "juplend") {
111
- return Boolean(credentials?.apiKey);
111
+ return true;
112
112
  }
113
113
 
114
114
  return Boolean(credentials?.apiKey && credentials.secret);
@@ -467,7 +467,7 @@ export class PrivateSubscriptionCoordinator {
467
467
  account: RegisteredAccountRecord,
468
468
  ): Promise<void> {
469
469
  const credentials = account.credentials;
470
- if (!credentials) {
470
+ if (!credentials && record.venue !== "juplend") {
471
471
  throw new AcexError(
472
472
  "CREDENTIALS_MISSING",
473
473
  `Account credentials are required for private subscriptions: ${account.accountId}`,
@@ -476,7 +476,7 @@ export class PrivateSubscriptionCoordinator {
476
476
 
477
477
  const adapter = this.getAdapter(record.venue);
478
478
  const stream = adapter.createPrivateStream(
479
- credentials,
479
+ credentials ?? {},
480
480
  {
481
481
  onAccountSnapshot: (snapshot) => {
482
482
  if (!record.accountSubscribed) {
@@ -106,7 +106,10 @@ export class AcexClientImpl implements AcexClient, ClientContext {
106
106
  this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
107
107
  const privateAdapters = [
108
108
  new BinancePrivateAdapter(),
109
- new JuplendPrivateAdapter(),
109
+ new JuplendPrivateAdapter(
110
+ options.account?.juplend?.rpcUrl,
111
+ options.account?.juplend?.jupApiKey,
112
+ ),
110
113
  ];
111
114
  this.privateAdapters = new Map(
112
115
  privateAdapters.map((adapter) => [adapter.venue, adapter]),
@@ -101,6 +101,13 @@ export class OrderManagerImpl
101
101
  async subscribeOrders(input: SubscribeOrdersInput): Promise<void> {
102
102
  this.context.assertStarted();
103
103
  const account = this.context.getRegisteredAccount(input.accountId);
104
+ if (account.venue === "juplend") {
105
+ throw this.createError(
106
+ "VENUE_NOT_SUPPORTED",
107
+ `Venue does not support private order subscriptions: ${account.venue}`,
108
+ { accountId: input.accountId, venue: account.venue },
109
+ );
110
+ }
104
111
  this.context.ensurePrivateCredentials(input.accountId);
105
112
 
106
113
  const record = this.getOrCreateRecord(input.accountId, account.venue);
@@ -635,6 +642,7 @@ export class OrderManagerImpl
635
642
 
636
643
  private createError(
637
644
  code:
645
+ | "VENUE_NOT_SUPPORTED"
638
646
  | "ORDER_CANCEL_ALL_FAILED"
639
647
  | "ORDER_CANCEL_FAILED"
640
648
  | "ORDER_CREATE_FAILED"
@@ -41,6 +41,8 @@ export interface AccountRuntimeOptions {
41
41
  };
42
42
  juplend?: {
43
43
  pollIntervalMs?: number;
44
+ rpcUrl?: string;
45
+ jupApiKey?: string;
44
46
  };
45
47
  }
46
48
 
@@ -64,14 +66,17 @@ export interface BinanceAccountOptions {
64
66
  recvWindow?: number;
65
67
  }
66
68
 
67
- export interface JuplendAccountCredentials {
68
- apiKey: string;
69
- }
70
-
71
- export interface JuplendAccountOptions {
72
- walletAddress: string;
73
- positionId?: string;
74
- }
69
+ export type JuplendAccountOptions =
70
+ | {
71
+ walletAddress: string;
72
+ vaultId?: string;
73
+ positionId?: string;
74
+ }
75
+ | {
76
+ walletAddress?: string;
77
+ vaultId: string;
78
+ positionId: string;
79
+ };
75
80
 
76
81
  export interface RegisterCexAccountInput {
77
82
  accountId: string;
@@ -83,7 +88,7 @@ export interface RegisterCexAccountInput {
83
88
  export interface RegisterJuplendAccountInput {
84
89
  accountId: string;
85
90
  venue: "juplend";
86
- credentials: JuplendAccountCredentials;
91
+ credentials?: AccountCredentials;
87
92
  options: JuplendAccountOptions;
88
93
  }
89
94