hyperliquid-prime 0.1.3 → 0.1.5
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 +87 -30
- package/dist/cli/program.d.ts.map +1 -1
- package/dist/cli/program.js +32 -7
- package/dist/cli/program.js.map +1 -1
- package/dist/collateral/manager.d.ts +2 -2
- package/dist/collateral/manager.d.ts.map +1 -1
- package/dist/collateral/manager.js +487 -91
- package/dist/collateral/manager.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/execution/executor.d.ts +23 -0
- package/dist/execution/executor.d.ts.map +1 -1
- package/dist/execution/executor.js +346 -102
- package/dist/execution/executor.js.map +1 -1
- package/dist/execution/types.d.ts +23 -2
- package/dist/execution/types.d.ts.map +1 -1
- package/dist/index.d.ts +51 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +227 -26
- package/dist/index.js.map +1 -1
- package/dist/market/registry.d.ts +3 -0
- package/dist/market/registry.d.ts.map +1 -1
- package/dist/market/registry.js +53 -23
- package/dist/market/registry.js.map +1 -1
- package/dist/market/types.d.ts +6 -0
- package/dist/market/types.d.ts.map +1 -1
- package/dist/position/manager.d.ts +6 -3
- package/dist/position/manager.d.ts.map +1 -1
- package/dist/position/manager.js +58 -31
- package/dist/position/manager.js.map +1 -1
- package/dist/provider/nktkas.d.ts +38 -3
- package/dist/provider/nktkas.d.ts.map +1 -1
- package/dist/provider/nktkas.js +202 -35
- package/dist/provider/nktkas.js.map +1 -1
- package/dist/provider/provider.d.ts +29 -3
- package/dist/provider/provider.d.ts.map +1 -1
- package/dist/provider/schemas.d.ts +933 -0
- package/dist/provider/schemas.d.ts.map +1 -0
- package/dist/provider/schemas.js +330 -0
- package/dist/provider/schemas.js.map +1 -0
- package/dist/provider/types.d.ts +88 -1
- package/dist/provider/types.d.ts.map +1 -1
- package/dist/router/router.d.ts +3 -3
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +98 -25
- package/dist/router/router.js.map +1 -1
- package/dist/router/scorer.d.ts +3 -1
- package/dist/router/scorer.d.ts.map +1 -1
- package/dist/router/scorer.js +28 -5
- package/dist/router/scorer.js.map +1 -1
- package/dist/router/types.d.ts +11 -0
- package/dist/router/types.d.ts.map +1 -1
- package/dist/router/types.js.map +1 -1
- package/dist/types/result.d.ts +16 -0
- package/dist/types/result.d.ts.map +1 -0
- package/dist/types/result.js +9 -0
- package/dist/types/result.js.map +1 -0
- package/dist/utils/asset.d.ts +2 -0
- package/dist/utils/asset.d.ts.map +1 -0
- package/dist/utils/asset.js +18 -0
- package/dist/utils/asset.js.map +1 -0
- package/dist/utils/order-precision.d.ts +16 -0
- package/dist/utils/order-precision.d.ts.map +1 -0
- package/dist/utils/order-precision.js +45 -0
- package/dist/utils/order-precision.js.map +1 -0
- package/package.json +7 -3
|
@@ -1,4 +1,263 @@
|
|
|
1
1
|
import { FillSimulator } from "../router/simulator.js";
|
|
2
|
+
import { formatPrice } from "@nktkas/hyperliquid/utils";
|
|
3
|
+
const KNOWN_STABLE_COLLATERAL = new Set([
|
|
4
|
+
"USDC",
|
|
5
|
+
"USDT",
|
|
6
|
+
"USDT0",
|
|
7
|
+
"USDH",
|
|
8
|
+
"USDE",
|
|
9
|
+
"USDS",
|
|
10
|
+
"USDB",
|
|
11
|
+
"USDD",
|
|
12
|
+
"DAI",
|
|
13
|
+
"FDUSD",
|
|
14
|
+
"PYUSD",
|
|
15
|
+
]);
|
|
16
|
+
const DEFAULT_QUOTE_TOKEN_PRIORITY = [
|
|
17
|
+
"USDC",
|
|
18
|
+
"USDT",
|
|
19
|
+
"USDT0",
|
|
20
|
+
"USDH",
|
|
21
|
+
"USDE",
|
|
22
|
+
"USDS",
|
|
23
|
+
"USDB",
|
|
24
|
+
"USDD",
|
|
25
|
+
"DAI",
|
|
26
|
+
"FDUSD",
|
|
27
|
+
"PYUSD",
|
|
28
|
+
];
|
|
29
|
+
function normalizeToken(value) {
|
|
30
|
+
return value.trim().toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
function parsePerpSpendableStableUsd(state) {
|
|
33
|
+
const totalRawUsdRaw = parseFloat(String(state.marginSummary?.totalRawUsd ?? ""));
|
|
34
|
+
const withdrawableRaw = parseFloat(String(state.withdrawable ?? ""));
|
|
35
|
+
const hasPositiveTotalRawUsd = Number.isFinite(totalRawUsdRaw) && totalRawUsdRaw > 0;
|
|
36
|
+
if (Number.isFinite(withdrawableRaw) && hasPositiveTotalRawUsd) {
|
|
37
|
+
// Use the more conservative spendable estimate for USDC collateral planning.
|
|
38
|
+
return Math.max(0, Math.min(withdrawableRaw, totalRawUsdRaw));
|
|
39
|
+
}
|
|
40
|
+
if (Number.isFinite(withdrawableRaw))
|
|
41
|
+
return Math.max(0, withdrawableRaw);
|
|
42
|
+
if (hasPositiveTotalRawUsd)
|
|
43
|
+
return Math.max(0, totalRawUsdRaw);
|
|
44
|
+
const accountValueRaw = parseFloat(String(state.marginSummary?.accountValue ?? "0"));
|
|
45
|
+
if (Number.isFinite(accountValueRaw))
|
|
46
|
+
return Math.max(0, accountValueRaw);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
function inferPerpUsdcBalanceFromSpot(params) {
|
|
50
|
+
const spotUsdcBalance = Math.max(0, params.spotBalances.get("USDC") ?? 0);
|
|
51
|
+
let nonUsdcStableSpotBalance = 0;
|
|
52
|
+
for (const [coinRaw, totalRaw] of params.spotBalances.entries()) {
|
|
53
|
+
const coin = normalizeToken(coinRaw);
|
|
54
|
+
if (coin === "USDC" || !isStableCollateralToken(coin))
|
|
55
|
+
continue;
|
|
56
|
+
const total = Number.isFinite(totalRaw) ? Math.max(0, totalRaw) : 0;
|
|
57
|
+
nonUsdcStableSpotBalance += total;
|
|
58
|
+
}
|
|
59
|
+
const spendableStableUsd = Number.isFinite(params.spendableStableUsd)
|
|
60
|
+
? Math.max(0, params.spendableStableUsd)
|
|
61
|
+
: 0;
|
|
62
|
+
const inferredPerp = Math.max(0, spendableStableUsd - nonUsdcStableSpotBalance - spotUsdcBalance);
|
|
63
|
+
return {
|
|
64
|
+
perpUsdcBalance: inferredPerp,
|
|
65
|
+
spotUsdcBalance,
|
|
66
|
+
nonUsdcStableSpotBalance,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function isStableCollateralToken(value) {
|
|
70
|
+
const token = normalizeToken(value);
|
|
71
|
+
if (KNOWN_STABLE_COLLATERAL.has(token))
|
|
72
|
+
return true;
|
|
73
|
+
return token.startsWith("USD");
|
|
74
|
+
}
|
|
75
|
+
function normalizeSzDecimals(value) {
|
|
76
|
+
if (!Number.isFinite(value ?? NaN))
|
|
77
|
+
return 6;
|
|
78
|
+
return Math.max(0, Math.min(12, Math.floor(value ?? 6)));
|
|
79
|
+
}
|
|
80
|
+
function quantizeSize(value, szDecimals) {
|
|
81
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
82
|
+
return 0;
|
|
83
|
+
const decimals = normalizeSzDecimals(szDecimals);
|
|
84
|
+
const factor = 10 ** decimals;
|
|
85
|
+
return Math.ceil((value - Number.EPSILON) * factor) / factor;
|
|
86
|
+
}
|
|
87
|
+
function toSizeString(value, decimals) {
|
|
88
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
89
|
+
return "0";
|
|
90
|
+
return value.toFixed(normalizeSzDecimals(decimals)).replace(/\.?0+$/, "");
|
|
91
|
+
}
|
|
92
|
+
function formatSpotLimitPrice(price, szDecimals) {
|
|
93
|
+
const safePrice = Number.isFinite(price) ? Math.max(price, 0) : 0;
|
|
94
|
+
if (safePrice <= 0) {
|
|
95
|
+
throw new Error("Invalid spot limit price after slippage adjustment.");
|
|
96
|
+
}
|
|
97
|
+
return formatPrice(safePrice, normalizeSzDecimals(szDecimals), "spot");
|
|
98
|
+
}
|
|
99
|
+
function quotePriority(token, preferredQuotes) {
|
|
100
|
+
const normalized = normalizeToken(token);
|
|
101
|
+
const idx = preferredQuotes.indexOf(normalized);
|
|
102
|
+
if (idx >= 0)
|
|
103
|
+
return idx;
|
|
104
|
+
if (isStableCollateralToken(normalized))
|
|
105
|
+
return preferredQuotes.length;
|
|
106
|
+
return Number.POSITIVE_INFINITY;
|
|
107
|
+
}
|
|
108
|
+
function buildStableSourceBalances(spotBalances, perpUsdcBalance) {
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const [coinRaw, totalRaw] of spotBalances.entries()) {
|
|
111
|
+
const coin = normalizeToken(coinRaw);
|
|
112
|
+
const total = Number.isFinite(totalRaw) ? Math.max(0, totalRaw) : 0;
|
|
113
|
+
if (total <= 0 || !isStableCollateralToken(coin))
|
|
114
|
+
continue;
|
|
115
|
+
out.set(coin, (out.get(coin) ?? 0) + total);
|
|
116
|
+
}
|
|
117
|
+
const perpUsdc = Number.isFinite(perpUsdcBalance) ? Math.max(0, perpUsdcBalance) : 0;
|
|
118
|
+
if (perpUsdc > 0) {
|
|
119
|
+
out.set("USDC", (out.get("USDC") ?? 0) + perpUsdc);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function consumeSourceBalance(sourceBalances, token, amount) {
|
|
124
|
+
const key = normalizeToken(token);
|
|
125
|
+
const available = sourceBalances.get(key) ?? 0;
|
|
126
|
+
sourceBalances.set(key, Math.max(0, available - Math.max(0, amount)));
|
|
127
|
+
}
|
|
128
|
+
function chooseSwapSourceToken(params) {
|
|
129
|
+
const target = normalizeToken(params.targetToken);
|
|
130
|
+
const preferred = params.preferredSource ? normalizeToken(params.preferredSource) : undefined;
|
|
131
|
+
const amountNeeded = Number.isFinite(params.amountNeeded) ? Math.max(0, params.amountNeeded) : 0;
|
|
132
|
+
const candidateEntries = [...params.sourceBalances.entries()]
|
|
133
|
+
.filter(([token, available]) => token !== target &&
|
|
134
|
+
available > 0 &&
|
|
135
|
+
isStableCollateralToken(token))
|
|
136
|
+
.sort((a, b) => {
|
|
137
|
+
const [tokenA, availableA] = a;
|
|
138
|
+
const [tokenB, availableB] = b;
|
|
139
|
+
const coversNeededA = availableA >= amountNeeded ? 1 : 0;
|
|
140
|
+
const coversNeededB = availableB >= amountNeeded ? 1 : 0;
|
|
141
|
+
if (coversNeededA !== coversNeededB)
|
|
142
|
+
return coversNeededB - coversNeededA;
|
|
143
|
+
const usdcPenaltyA = tokenA === "USDC" ? 1 : 0;
|
|
144
|
+
const usdcPenaltyB = tokenB === "USDC" ? 1 : 0;
|
|
145
|
+
if (usdcPenaltyA !== usdcPenaltyB)
|
|
146
|
+
return usdcPenaltyA - usdcPenaltyB;
|
|
147
|
+
if (availableA !== availableB)
|
|
148
|
+
return availableB - availableA;
|
|
149
|
+
return quotePriority(tokenA, DEFAULT_QUOTE_TOKEN_PRIORITY)
|
|
150
|
+
- quotePriority(tokenB, DEFAULT_QUOTE_TOKEN_PRIORITY);
|
|
151
|
+
});
|
|
152
|
+
if (candidateEntries.length > 0) {
|
|
153
|
+
return candidateEntries[0][0];
|
|
154
|
+
}
|
|
155
|
+
if (preferred && preferred !== target) {
|
|
156
|
+
return preferred;
|
|
157
|
+
}
|
|
158
|
+
return "USDC";
|
|
159
|
+
}
|
|
160
|
+
function isLikelyInsufficientBalanceError(message) {
|
|
161
|
+
return /insufficient|not enough|available|balance|margin/i.test(message);
|
|
162
|
+
}
|
|
163
|
+
function resolveSpotSwapMarket(tokenByIndex, pairs, targetToken, swapFrom) {
|
|
164
|
+
const target = normalizeToken(targetToken);
|
|
165
|
+
const from = normalizeToken(swapFrom);
|
|
166
|
+
const candidates = [];
|
|
167
|
+
for (const pair of pairs) {
|
|
168
|
+
if (pair.tokens.length < 2)
|
|
169
|
+
continue;
|
|
170
|
+
const base = tokenByIndex.get(pair.tokens[0]);
|
|
171
|
+
const quote = tokenByIndex.get(pair.tokens[1]);
|
|
172
|
+
if (!base || !quote)
|
|
173
|
+
continue;
|
|
174
|
+
const baseToken = normalizeToken(base.name);
|
|
175
|
+
const quoteToken = normalizeToken(quote.name);
|
|
176
|
+
if (baseToken === target && quoteToken === from) {
|
|
177
|
+
// target/swapFrom pair: buy base to receive target token.
|
|
178
|
+
candidates.push({
|
|
179
|
+
pair,
|
|
180
|
+
baseToken: base.name,
|
|
181
|
+
quoteToken: quote.name,
|
|
182
|
+
baseSzDecimals: base.szDecimals,
|
|
183
|
+
orientationRank: 0,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (baseToken === from && quoteToken === target) {
|
|
188
|
+
// swapFrom/target pair: sell base to receive target token as quote.
|
|
189
|
+
candidates.push({
|
|
190
|
+
pair,
|
|
191
|
+
baseToken: base.name,
|
|
192
|
+
quoteToken: quote.name,
|
|
193
|
+
baseSzDecimals: base.szDecimals,
|
|
194
|
+
orientationRank: 1,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (candidates.length === 0) {
|
|
199
|
+
const stableFallback = [
|
|
200
|
+
from,
|
|
201
|
+
...DEFAULT_QUOTE_TOKEN_PRIORITY,
|
|
202
|
+
].filter((token, index, values) => values.indexOf(token) === index);
|
|
203
|
+
for (const pair of pairs) {
|
|
204
|
+
if (pair.tokens.length < 2)
|
|
205
|
+
continue;
|
|
206
|
+
const base = tokenByIndex.get(pair.tokens[0]);
|
|
207
|
+
const quote = tokenByIndex.get(pair.tokens[1]);
|
|
208
|
+
if (!base || !quote)
|
|
209
|
+
continue;
|
|
210
|
+
if (normalizeToken(base.name) !== target)
|
|
211
|
+
continue;
|
|
212
|
+
if (quotePriority(quote.name, stableFallback) === Number.POSITIVE_INFINITY)
|
|
213
|
+
continue;
|
|
214
|
+
candidates.push({
|
|
215
|
+
pair,
|
|
216
|
+
baseToken: base.name,
|
|
217
|
+
quoteToken: quote.name,
|
|
218
|
+
baseSzDecimals: base.szDecimals,
|
|
219
|
+
orientationRank: 2,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (candidates.length === 0) {
|
|
224
|
+
throw new Error(`No spot pair found for ${swapFrom}->${targetToken}`);
|
|
225
|
+
}
|
|
226
|
+
candidates.sort((a, b) => {
|
|
227
|
+
if (a.orientationRank !== b.orientationRank) {
|
|
228
|
+
return a.orientationRank - b.orientationRank;
|
|
229
|
+
}
|
|
230
|
+
if (Boolean(a.pair.isCanonical) !== Boolean(b.pair.isCanonical)) {
|
|
231
|
+
return a.pair.isCanonical ? -1 : 1;
|
|
232
|
+
}
|
|
233
|
+
return a.pair.index - b.pair.index;
|
|
234
|
+
});
|
|
235
|
+
const selected = candidates[0];
|
|
236
|
+
if (selected.orientationRank === 1) {
|
|
237
|
+
return {
|
|
238
|
+
pairId: selected.pair.name,
|
|
239
|
+
pairLabel: `${selected.baseToken}/${selected.quoteToken}`,
|
|
240
|
+
assetIndex: 10000 + selected.pair.index,
|
|
241
|
+
baseSzDecimals: selected.baseSzDecimals,
|
|
242
|
+
side: "sell",
|
|
243
|
+
priceSide: "bids",
|
|
244
|
+
sizeMultiplier: 1.01,
|
|
245
|
+
sizeDivisor: 1,
|
|
246
|
+
quoteToken: selected.quoteToken,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
pairId: selected.pair.name,
|
|
251
|
+
pairLabel: `${selected.baseToken}/${selected.quoteToken}`,
|
|
252
|
+
assetIndex: 10000 + selected.pair.index,
|
|
253
|
+
baseSzDecimals: selected.baseSzDecimals,
|
|
254
|
+
side: "buy",
|
|
255
|
+
priceSide: "asks",
|
|
256
|
+
sizeMultiplier: 1,
|
|
257
|
+
sizeDivisor: 1,
|
|
258
|
+
quoteToken: selected.quoteToken,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
2
261
|
/**
|
|
3
262
|
* CollateralManager handles two concerns:
|
|
4
263
|
* 1. Estimation (read-only): What collateral swaps are needed and how much will they cost?
|
|
@@ -31,11 +290,26 @@ export class CollateralManager {
|
|
|
31
290
|
const spotState = await this.provider.spotClearinghouseState(userAddress);
|
|
32
291
|
const balanceMap = new Map();
|
|
33
292
|
for (const b of spotState.balances) {
|
|
34
|
-
|
|
293
|
+
const parsed = parseFloat(b.total);
|
|
294
|
+
balanceMap.set(normalizeToken(b.coin), Number.isFinite(parsed) ? parsed : 0);
|
|
35
295
|
}
|
|
36
296
|
// Get perp balance (USDC is the perp native collateral)
|
|
37
297
|
const perpState = await this.provider.clearinghouseState(userAddress);
|
|
38
|
-
const
|
|
298
|
+
const spendableStableUsd = parsePerpSpendableStableUsd(perpState);
|
|
299
|
+
const { perpUsdcBalance, spotUsdcBalance, nonUsdcStableSpotBalance, } = inferPerpUsdcBalanceFromSpot({
|
|
300
|
+
spendableStableUsd,
|
|
301
|
+
spotBalances: balanceMap,
|
|
302
|
+
});
|
|
303
|
+
this.logger.warn({
|
|
304
|
+
user: userAddress,
|
|
305
|
+
accountValue: perpState.marginSummary?.accountValue,
|
|
306
|
+
totalRawUsd: perpState.marginSummary?.totalRawUsd,
|
|
307
|
+
withdrawable: perpState.withdrawable,
|
|
308
|
+
spendableStableUsd,
|
|
309
|
+
nonUsdcStableSpotBalance,
|
|
310
|
+
spotUsdcBalance,
|
|
311
|
+
plannedPerpUsdcAvailable: perpUsdcBalance,
|
|
312
|
+
}, "[debug] Collateral balance inputs");
|
|
39
313
|
// Group allocations by collateral type
|
|
40
314
|
const collateralNeeds = new Map();
|
|
41
315
|
for (const alloc of allocations) {
|
|
@@ -43,76 +317,91 @@ export class CollateralManager {
|
|
|
43
317
|
const amount = alloc.estimatedCost; // USD value needed
|
|
44
318
|
collateralNeeds.set(token, (collateralNeeds.get(token) ?? 0) + amount);
|
|
45
319
|
}
|
|
320
|
+
const sourceBalances = buildStableSourceBalances(balanceMap, perpUsdcBalance);
|
|
46
321
|
const requirements = [];
|
|
47
322
|
let swapsNeeded = false;
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
swapFrom: "USDC",
|
|
59
|
-
estimatedSwapCostBps: 0,
|
|
60
|
-
});
|
|
61
|
-
continue;
|
|
323
|
+
const swapCostJobs = [];
|
|
324
|
+
for (const [tokenRaw, amountNeeded] of collateralNeeds) {
|
|
325
|
+
const token = normalizeToken(tokenRaw);
|
|
326
|
+
const spotTokenBalance = balanceMap.get(token) ?? 0;
|
|
327
|
+
const currentBalance = token === "USDC"
|
|
328
|
+
? perpUsdcBalance + spotTokenBalance
|
|
329
|
+
: spotTokenBalance;
|
|
330
|
+
const reservedExistingBalance = Math.min(amountNeeded, currentBalance);
|
|
331
|
+
if (reservedExistingBalance > 0) {
|
|
332
|
+
consumeSourceBalance(sourceBalances, token, reservedExistingBalance);
|
|
62
333
|
}
|
|
63
|
-
// Non-USDC: needs to be in spot balance
|
|
64
|
-
const currentBalance = balanceMap.get(token) ?? 0;
|
|
65
334
|
const shortfall = Math.max(0, amountNeeded - currentBalance);
|
|
66
|
-
|
|
335
|
+
let swapFrom = token;
|
|
67
336
|
if (shortfall > 0) {
|
|
68
337
|
swapsNeeded = true;
|
|
69
|
-
|
|
338
|
+
const sourceAmountNeeded = shortfall * 1.01;
|
|
339
|
+
swapFrom = chooseSwapSourceToken({
|
|
340
|
+
sourceBalances,
|
|
341
|
+
targetToken: token,
|
|
342
|
+
amountNeeded: sourceAmountNeeded,
|
|
343
|
+
});
|
|
344
|
+
consumeSourceBalance(sourceBalances, swapFrom, sourceAmountNeeded);
|
|
70
345
|
}
|
|
71
|
-
|
|
72
|
-
const swapCostMap = new Map();
|
|
73
|
-
await Promise.all([...swapShortfalls.entries()].map(async ([token, shortfall]) => {
|
|
74
|
-
const swapCostBps = await this.estimateSwapCost("USDC", token, shortfall);
|
|
75
|
-
swapCostMap.set(token, swapCostBps);
|
|
76
|
-
}));
|
|
77
|
-
for (const [token, amountNeeded] of collateralNeeds) {
|
|
78
|
-
if (token === "USDC")
|
|
79
|
-
continue;
|
|
80
|
-
const currentBalance = tokenBalances.get(token) ?? 0;
|
|
81
|
-
const shortfall = Math.max(0, amountNeeded - currentBalance);
|
|
346
|
+
const requirementIndex = requirements.length;
|
|
82
347
|
requirements.push({
|
|
83
348
|
token,
|
|
84
349
|
amountNeeded,
|
|
85
350
|
currentBalance,
|
|
86
351
|
shortfall,
|
|
87
|
-
swapFrom
|
|
88
|
-
estimatedSwapCostBps:
|
|
352
|
+
swapFrom,
|
|
353
|
+
estimatedSwapCostBps: 0,
|
|
89
354
|
});
|
|
355
|
+
if (shortfall > 0) {
|
|
356
|
+
swapCostJobs.push({
|
|
357
|
+
index: requirementIndex,
|
|
358
|
+
fromToken: swapFrom,
|
|
359
|
+
toToken: token,
|
|
360
|
+
amount: shortfall,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const swapCosts = await Promise.all(swapCostJobs.map(async (job) => {
|
|
365
|
+
const bps = await this.estimateSwapCost(job.fromToken, job.toToken, job.amount);
|
|
366
|
+
return { index: job.index, bps };
|
|
367
|
+
}));
|
|
368
|
+
for (const { index, bps } of swapCosts) {
|
|
369
|
+
requirements[index].estimatedSwapCostBps = bps;
|
|
90
370
|
}
|
|
91
371
|
const totalSwapCostBps = this.weightedSwapCost(requirements, allocations);
|
|
92
372
|
return {
|
|
93
373
|
requirements,
|
|
94
374
|
totalSwapCostBps,
|
|
95
375
|
swapsNeeded,
|
|
96
|
-
|
|
376
|
+
// In trader app flow, unified abstraction is configured during setup.
|
|
377
|
+
// Avoid write-side abstraction changes during execution.
|
|
378
|
+
abstractionEnabled: true,
|
|
97
379
|
};
|
|
98
380
|
}
|
|
99
381
|
/**
|
|
100
382
|
* Estimate the cost in basis points to swap fromToken to toToken on the spot market.
|
|
101
383
|
* Uses the spot L2Book to simulate the swap.
|
|
102
384
|
*/
|
|
103
|
-
async estimateSwapCost(
|
|
385
|
+
async estimateSwapCost(fromToken, toToken, amount) {
|
|
104
386
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
387
|
+
const spotMeta = await this.provider.spotMeta();
|
|
388
|
+
const tokenByIndex = new Map();
|
|
389
|
+
for (const token of spotMeta.tokens) {
|
|
390
|
+
tokenByIndex.set(token.index, token);
|
|
391
|
+
}
|
|
392
|
+
const market = resolveSpotSwapMarket(tokenByIndex, spotMeta.universe, toToken, fromToken);
|
|
393
|
+
const byId = await this.provider.l2Book(market.pairId);
|
|
394
|
+
const needsAsks = market.priceSide === "asks";
|
|
395
|
+
const hasDepth = needsAsks ? byId.levels[1].length > 0 : byId.levels[0].length > 0;
|
|
396
|
+
const book = hasDepth || normalizeToken(market.pairId) === normalizeToken(market.pairLabel)
|
|
397
|
+
? byId
|
|
398
|
+
: await this.provider.l2Book(market.pairLabel);
|
|
109
399
|
if (book.levels[0].length === 0 && book.levels[1].length === 0) {
|
|
110
400
|
// No spot book available — return conservative default
|
|
111
401
|
return 50;
|
|
112
402
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const sim = this.simulator.simulate(book, "buy", amount);
|
|
403
|
+
const simSide = market.side === "buy" ? "buy" : "sell";
|
|
404
|
+
const sim = this.simulator.simulate(book, simSide, amount);
|
|
116
405
|
if (!sim) {
|
|
117
406
|
return 100; // Insufficient spot depth
|
|
118
407
|
}
|
|
@@ -128,90 +417,197 @@ export class CollateralManager {
|
|
|
128
417
|
* Execute collateral preparation: enable abstraction, perform swaps.
|
|
129
418
|
* Call this before placing split orders.
|
|
130
419
|
*/
|
|
131
|
-
async prepare(plan,
|
|
420
|
+
async prepare(plan, userAddress) {
|
|
132
421
|
const swapsExecuted = [];
|
|
133
|
-
|
|
422
|
+
const abstractionWasEnabled = false;
|
|
134
423
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.logger.info("Enabling DEX abstraction");
|
|
138
|
-
await this.provider.setDexAbstraction(true);
|
|
139
|
-
abstractionWasEnabled = true;
|
|
140
|
-
}
|
|
424
|
+
const signerAddress = this.provider.getSignerAddress?.();
|
|
425
|
+
const isAgentSession = Boolean(signerAddress && signerAddress.toLowerCase() !== userAddress.toLowerCase());
|
|
141
426
|
const spotMeta = await this.provider.spotMeta();
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
427
|
+
const tokenByIndex = new Map();
|
|
428
|
+
for (const token of spotMeta.tokens) {
|
|
429
|
+
tokenByIndex.set(token.index, token);
|
|
430
|
+
}
|
|
431
|
+
const pairs = spotMeta.universe;
|
|
432
|
+
const spotState = await this.provider.spotClearinghouseState(userAddress);
|
|
433
|
+
const spotBalanceMap = new Map();
|
|
434
|
+
for (const balance of spotState.balances) {
|
|
435
|
+
const parsed = parseFloat(balance.total);
|
|
436
|
+
spotBalanceMap.set(normalizeToken(balance.coin), Number.isFinite(parsed) ? parsed : 0);
|
|
150
437
|
}
|
|
438
|
+
const perpState = await this.provider.clearinghouseState(userAddress);
|
|
439
|
+
const spendableStableUsd = parsePerpSpendableStableUsd(perpState);
|
|
440
|
+
const { perpUsdcBalance } = inferPerpUsdcBalanceFromSpot({
|
|
441
|
+
spendableStableUsd,
|
|
442
|
+
spotBalances: spotBalanceMap,
|
|
443
|
+
});
|
|
444
|
+
const sourceBalances = buildStableSourceBalances(spotBalanceMap, perpUsdcBalance);
|
|
151
445
|
// Step 2: Execute swaps for each requirement with shortfall
|
|
152
446
|
for (const req of plan.requirements) {
|
|
153
|
-
if (req.shortfall <= 0
|
|
447
|
+
if (req.shortfall <= 0)
|
|
154
448
|
continue;
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
449
|
+
const sourceAmountNeeded = req.shortfall * 1.01;
|
|
450
|
+
const swapFrom = chooseSwapSourceToken({
|
|
451
|
+
sourceBalances,
|
|
452
|
+
targetToken: req.token,
|
|
453
|
+
amountNeeded: sourceAmountNeeded,
|
|
454
|
+
preferredSource: req.swapFrom,
|
|
455
|
+
});
|
|
456
|
+
const market = resolveSpotSwapMarket(tokenByIndex, pairs, req.token, swapFrom);
|
|
159
457
|
// Place spot order to swap USDC → target token
|
|
160
458
|
// Spot orders use the same provider.placeOrder with spot asset indices
|
|
161
|
-
const
|
|
162
|
-
|
|
459
|
+
const byId = await this.provider.l2Book(market.pairId);
|
|
460
|
+
const needsAsks = market.priceSide === "asks";
|
|
461
|
+
const hasDepth = needsAsks ? byId.levels[1].length > 0 : byId.levels[0].length > 0;
|
|
462
|
+
const spotBook = hasDepth || normalizeToken(market.pairId) === normalizeToken(market.pairLabel)
|
|
463
|
+
? byId
|
|
464
|
+
: await this.provider.l2Book(market.pairLabel);
|
|
465
|
+
const topLevels = market.priceSide === "asks"
|
|
466
|
+
? spotBook.levels[1]
|
|
467
|
+
: spotBook.levels[0];
|
|
468
|
+
if (topLevels.length === 0) {
|
|
163
469
|
return {
|
|
164
470
|
success: false,
|
|
165
471
|
swapsExecuted,
|
|
166
472
|
abstractionWasEnabled,
|
|
167
|
-
error: `No spot liquidity for ${req.token}`,
|
|
473
|
+
error: `No spot liquidity for ${req.token} (${market.pairLabel})`,
|
|
168
474
|
};
|
|
169
475
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const limitPrice = (bestAsk * 1.005).toFixed(6); // 0.5% slippage on swap
|
|
173
|
-
const spotToken = tokenByName.get(req.token);
|
|
174
|
-
if (!spotToken) {
|
|
476
|
+
const topPrice = parseFloat(topLevels[0].px);
|
|
477
|
+
if (!Number.isFinite(topPrice) || topPrice <= 0) {
|
|
175
478
|
return {
|
|
176
479
|
success: false,
|
|
177
480
|
swapsExecuted,
|
|
178
481
|
abstractionWasEnabled,
|
|
179
|
-
error: `
|
|
482
|
+
error: `Invalid spot top-of-book price for ${req.token} (${market.pairLabel})`,
|
|
180
483
|
};
|
|
181
484
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
485
|
+
const limitPriceRaw = market.side === "buy"
|
|
486
|
+
? topPrice * 1.005
|
|
487
|
+
: topPrice * 0.995;
|
|
488
|
+
const limitPrice = formatSpotLimitPrice(limitPriceRaw, market.baseSzDecimals);
|
|
489
|
+
const orderSize = market.side === "buy"
|
|
490
|
+
? req.shortfall
|
|
491
|
+
: req.shortfall / topPrice;
|
|
492
|
+
const finalOrderSizeRaw = orderSize * market.sizeMultiplier / market.sizeDivisor;
|
|
493
|
+
const finalOrderSize = quantizeSize(finalOrderSizeRaw, market.baseSzDecimals);
|
|
494
|
+
const finalOrderSizeStr = toSizeString(finalOrderSize, market.baseSzDecimals);
|
|
495
|
+
if (!Number.isFinite(finalOrderSize) || finalOrderSize <= 0) {
|
|
186
496
|
return {
|
|
187
497
|
success: false,
|
|
188
498
|
swapsExecuted,
|
|
189
499
|
abstractionWasEnabled,
|
|
190
|
-
error: `
|
|
500
|
+
error: `Invalid spot order size for ${req.token} (${market.pairLabel})`,
|
|
191
501
|
};
|
|
192
502
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
503
|
+
if (finalOrderSizeStr === "0") {
|
|
504
|
+
return {
|
|
505
|
+
success: false,
|
|
506
|
+
swapsExecuted,
|
|
507
|
+
abstractionWasEnabled,
|
|
508
|
+
error: `Spot order size rounded to zero for ${req.token} (${market.pairLabel})`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
this.logger.info({
|
|
512
|
+
from: swapFrom,
|
|
513
|
+
token: req.token,
|
|
514
|
+
size: finalOrderSize,
|
|
515
|
+
side: market.side,
|
|
516
|
+
limitPrice,
|
|
517
|
+
spotAssetIndex: market.assetIndex,
|
|
518
|
+
pair: market.pairLabel,
|
|
519
|
+
}, "Placing spot swap order");
|
|
520
|
+
const placeSwapOrder = async () => {
|
|
521
|
+
const result = await this.provider.placeOrder({
|
|
522
|
+
assetIndex: market.assetIndex,
|
|
523
|
+
isBuy: market.side === "buy",
|
|
524
|
+
price: limitPrice,
|
|
525
|
+
size: finalOrderSizeStr,
|
|
526
|
+
reduceOnly: false,
|
|
527
|
+
orderType: { limit: { tif: "Ioc" } },
|
|
528
|
+
});
|
|
529
|
+
const status = result.statuses[0];
|
|
530
|
+
if (status && typeof status === "object" && "error" in status) {
|
|
531
|
+
throw new Error(`Spot swap rejected for ${req.token} (${market.pairLabel}): ${status.error} (side ${market.side}, px ${limitPrice}, size ${finalOrderSizeStr}, szDecimals ${market.baseSzDecimals})`);
|
|
532
|
+
}
|
|
533
|
+
if (status && typeof status === "string") {
|
|
534
|
+
if (status === "waitingForFill" || status === "waitingForTrigger") {
|
|
535
|
+
throw new Error(`Spot swap did not fill immediately for ${req.token} (${market.pairLabel}): ${status}`);
|
|
536
|
+
}
|
|
537
|
+
throw new Error(`Unknown spot swap status for ${req.token}: ${status}`);
|
|
538
|
+
}
|
|
539
|
+
if (status && typeof status === "object" && "filled" in status) {
|
|
540
|
+
return status.filled.totalSz;
|
|
541
|
+
}
|
|
542
|
+
return "0";
|
|
543
|
+
};
|
|
204
544
|
let filled = "0";
|
|
205
|
-
|
|
206
|
-
filled =
|
|
545
|
+
try {
|
|
546
|
+
filled = await placeSwapOrder();
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
550
|
+
if (swapFrom !== "USDC" || !isLikelyInsufficientBalanceError(message)) {
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
swapsExecuted,
|
|
554
|
+
abstractionWasEnabled,
|
|
555
|
+
error: message,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (isAgentSession) {
|
|
559
|
+
return {
|
|
560
|
+
success: false,
|
|
561
|
+
swapsExecuted,
|
|
562
|
+
abstractionWasEnabled,
|
|
563
|
+
error: `Spot swap for ${req.token} failed (insufficient spot USDC) and ` +
|
|
564
|
+
"usdClassTransfer requires master-wallet signing. " +
|
|
565
|
+
"Transfer USDC from Perp to Spot manually or use the master wallet.",
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
this.logger.info({ amount: sourceAmountNeeded, token: req.token }, "Transferring USDC to spot for swap fallback");
|
|
569
|
+
await this.provider.usdClassTransfer(sourceAmountNeeded, false);
|
|
570
|
+
filled = await placeSwapOrder();
|
|
207
571
|
}
|
|
572
|
+
consumeSourceBalance(sourceBalances, swapFrom, sourceAmountNeeded);
|
|
208
573
|
swapsExecuted.push({
|
|
209
|
-
from:
|
|
574
|
+
from: swapFrom,
|
|
210
575
|
to: req.token,
|
|
211
576
|
amount: req.shortfall.toString(),
|
|
212
577
|
filled,
|
|
213
578
|
});
|
|
214
579
|
}
|
|
580
|
+
// Post-swap balance assertion: verify each required token's balance
|
|
581
|
+
// meets the needed amount after swaps completed.
|
|
582
|
+
if (swapsExecuted.length > 0) {
|
|
583
|
+
const postSwapSpotState = await this.provider.spotClearinghouseState(userAddress);
|
|
584
|
+
const postSwapBalances = new Map();
|
|
585
|
+
for (const b of postSwapSpotState.balances) {
|
|
586
|
+
const parsed = parseFloat(b.total);
|
|
587
|
+
postSwapBalances.set(normalizeToken(b.coin), Number.isFinite(parsed) ? parsed : 0);
|
|
588
|
+
}
|
|
589
|
+
for (const req of plan.requirements) {
|
|
590
|
+
if (req.shortfall <= 0)
|
|
591
|
+
continue;
|
|
592
|
+
const postBalance = postSwapBalances.get(normalizeToken(req.token)) ?? 0;
|
|
593
|
+
// Allow 2% tolerance for rounding, slippage, and fees
|
|
594
|
+
const threshold = req.amountNeeded * 0.98;
|
|
595
|
+
if (postBalance < threshold) {
|
|
596
|
+
this.logger.warn({
|
|
597
|
+
token: req.token,
|
|
598
|
+
expected: req.amountNeeded,
|
|
599
|
+
actual: postBalance,
|
|
600
|
+
threshold,
|
|
601
|
+
}, "Post-swap balance below required threshold");
|
|
602
|
+
return {
|
|
603
|
+
success: false,
|
|
604
|
+
swapsExecuted,
|
|
605
|
+
abstractionWasEnabled,
|
|
606
|
+
error: `Post-swap balance for ${req.token} is ${postBalance.toFixed(2)}, need ${req.amountNeeded.toFixed(2)} (${((postBalance / req.amountNeeded) * 100).toFixed(1)}% of required)`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
215
611
|
return {
|
|
216
612
|
success: true,
|
|
217
613
|
swapsExecuted,
|