@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 +18 -9
- package/docs/api.md +41 -16
- package/package.json +3 -1
- package/src/adapters/juplend/lend-read.ts +150 -0
- package/src/adapters/juplend/private-adapter.ts +432 -162
- package/src/client/context.ts +1 -1
- package/src/client/private-subscription-coordinator.ts +2 -2
- package/src/client/runtime.ts +4 -1
- package/src/managers/order-manager.ts +8 -0
- package/src/types/shared.ts +14 -9
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
|
|
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 使用
|
|
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
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
|
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
|
|
1448
|
-
| `CREDENTIALS_MISSING` | 私有订阅 / 下单缺必要凭证,例如 Binance 缺 `apiKey/secret`
|
|
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
|
|
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.
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
34
|
+
interface JuplendVaultMetadata {
|
|
40
35
|
id?: number | string;
|
|
41
36
|
vaultId?: number | string;
|
|
42
|
-
supplyToken?:
|
|
43
|
-
borrowToken?:
|
|
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
|
|
58
|
+
walletAddress?: string;
|
|
59
|
+
vaultId?: string;
|
|
72
60
|
positionId?: string;
|
|
73
61
|
}
|
|
74
62
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
interface JuplendPriceApiEntry {
|
|
64
|
+
usdPrice?: number | string;
|
|
65
|
+
price?: number | string;
|
|
66
|
+
decimals?: number | string;
|
|
67
|
+
}
|
|
80
68
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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"
|
|
102
|
-
throw new Error("options.walletAddress
|
|
106
|
+
if (walletAddress !== undefined && typeof walletAddress !== "string") {
|
|
107
|
+
throw new Error("options.walletAddress must be a string");
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
const
|
|
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(
|
|
117
|
-
|
|
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:
|
|
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
|
|
126
|
-
|
|
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
|
|
142
|
-
if (!
|
|
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
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
apiKey
|
|
287
|
-
): Promise<
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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(
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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 =
|
|
336
|
-
|
|
337
|
-
if (supplyAsset
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 =
|
|
346
|
-
|
|
347
|
-
if (borrowAsset
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
408
|
-
|
|
409
|
-
loadVaults(receivedAt),
|
|
410
|
-
]);
|
|
411
|
-
const mapped = mapAccount(
|
|
412
|
-
portfolio,
|
|
413
|
-
vaults,
|
|
677
|
+
const mapped = await mapAccount(
|
|
678
|
+
juplendOptions,
|
|
414
679
|
receivedAt,
|
|
415
|
-
|
|
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
|
-
|
|
517
|
-
|
|
783
|
+
enrichmentCache = new Map<string, JuplendVaultEnrichmentCacheEntry>();
|
|
784
|
+
enrichmentCachePromise = new Map<
|
|
785
|
+
string,
|
|
786
|
+
Promise<Map<string, JuplendVaultMetadata>>
|
|
787
|
+
>();
|
|
518
788
|
}
|
package/src/client/context.ts
CHANGED
|
@@ -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) {
|
package/src/client/runtime.ts
CHANGED
|
@@ -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"
|
package/src/types/shared.ts
CHANGED
|
@@ -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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
91
|
+
credentials?: AccountCredentials;
|
|
87
92
|
options: JuplendAccountOptions;
|
|
88
93
|
}
|
|
89
94
|
|