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.
Files changed (67) hide show
  1. package/README.md +87 -30
  2. package/dist/cli/program.d.ts.map +1 -1
  3. package/dist/cli/program.js +32 -7
  4. package/dist/cli/program.js.map +1 -1
  5. package/dist/collateral/manager.d.ts +2 -2
  6. package/dist/collateral/manager.d.ts.map +1 -1
  7. package/dist/collateral/manager.js +487 -91
  8. package/dist/collateral/manager.js.map +1 -1
  9. package/dist/config.d.ts +5 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/execution/executor.d.ts +23 -0
  13. package/dist/execution/executor.d.ts.map +1 -1
  14. package/dist/execution/executor.js +346 -102
  15. package/dist/execution/executor.js.map +1 -1
  16. package/dist/execution/types.d.ts +23 -2
  17. package/dist/execution/types.d.ts.map +1 -1
  18. package/dist/index.d.ts +51 -12
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +227 -26
  21. package/dist/index.js.map +1 -1
  22. package/dist/market/registry.d.ts +3 -0
  23. package/dist/market/registry.d.ts.map +1 -1
  24. package/dist/market/registry.js +53 -23
  25. package/dist/market/registry.js.map +1 -1
  26. package/dist/market/types.d.ts +6 -0
  27. package/dist/market/types.d.ts.map +1 -1
  28. package/dist/position/manager.d.ts +6 -3
  29. package/dist/position/manager.d.ts.map +1 -1
  30. package/dist/position/manager.js +58 -31
  31. package/dist/position/manager.js.map +1 -1
  32. package/dist/provider/nktkas.d.ts +38 -3
  33. package/dist/provider/nktkas.d.ts.map +1 -1
  34. package/dist/provider/nktkas.js +202 -35
  35. package/dist/provider/nktkas.js.map +1 -1
  36. package/dist/provider/provider.d.ts +29 -3
  37. package/dist/provider/provider.d.ts.map +1 -1
  38. package/dist/provider/schemas.d.ts +933 -0
  39. package/dist/provider/schemas.d.ts.map +1 -0
  40. package/dist/provider/schemas.js +330 -0
  41. package/dist/provider/schemas.js.map +1 -0
  42. package/dist/provider/types.d.ts +88 -1
  43. package/dist/provider/types.d.ts.map +1 -1
  44. package/dist/router/router.d.ts +3 -3
  45. package/dist/router/router.d.ts.map +1 -1
  46. package/dist/router/router.js +98 -25
  47. package/dist/router/router.js.map +1 -1
  48. package/dist/router/scorer.d.ts +3 -1
  49. package/dist/router/scorer.d.ts.map +1 -1
  50. package/dist/router/scorer.js +28 -5
  51. package/dist/router/scorer.js.map +1 -1
  52. package/dist/router/types.d.ts +11 -0
  53. package/dist/router/types.d.ts.map +1 -1
  54. package/dist/router/types.js.map +1 -1
  55. package/dist/types/result.d.ts +16 -0
  56. package/dist/types/result.d.ts.map +1 -0
  57. package/dist/types/result.js +9 -0
  58. package/dist/types/result.js.map +1 -0
  59. package/dist/utils/asset.d.ts +2 -0
  60. package/dist/utils/asset.d.ts.map +1 -0
  61. package/dist/utils/asset.js +18 -0
  62. package/dist/utils/asset.js.map +1 -0
  63. package/dist/utils/order-precision.d.ts +16 -0
  64. package/dist/utils/order-precision.d.ts.map +1 -0
  65. package/dist/utils/order-precision.js +45 -0
  66. package/dist/utils/order-precision.js.map +1 -0
  67. 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
- balanceMap.set(b.coin, parseFloat(b.total));
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 perpUsdcBalance = parseFloat(perpState.marginSummary.accountValue);
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 swapShortfalls = new Map();
49
- const tokenBalances = new Map();
50
- for (const [token, amountNeeded] of collateralNeeds) {
51
- if (token === "USDC") {
52
- // USDC comes from perp balance automatically via abstraction
53
- requirements.push({
54
- token,
55
- amountNeeded,
56
- currentBalance: perpUsdcBalance,
57
- shortfall: 0, // Abstraction handles USDC automatically
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
- tokenBalances.set(token, currentBalance);
335
+ let swapFrom = token;
67
336
  if (shortfall > 0) {
68
337
  swapsNeeded = true;
69
- swapShortfalls.set(token, shortfall);
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: "USDC",
88
- estimatedSwapCostBps: swapCostMap.get(token) ?? 0,
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
- abstractionEnabled: false, // Will be checked during prepare()
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(_fromToken, toToken, amount) {
385
+ async estimateSwapCost(fromToken, toToken, amount) {
104
386
  try {
105
- // Fetch the spot book for the target token
106
- // On Hyperliquid, stablecoin pairs typically use @{index} format
107
- // For now, try the token name directly
108
- const book = await this.provider.l2Book(toToken);
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
- // Simulate buying `amount` worth of toToken on the spot market
114
- // Since stablecoins trade near $1, amount ≈ size
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, _userAddress) {
420
+ async prepare(plan, userAddress) {
132
421
  const swapsExecuted = [];
133
- let abstractionWasEnabled = false;
422
+ const abstractionWasEnabled = false;
134
423
  try {
135
- // Step 1: Enable DEX abstraction if not already enabled
136
- if (!plan.abstractionEnabled) {
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 tokenByName = new Map(spotMeta.tokens.map((token) => [token.name, token]));
143
- const pairByTokenIndex = new Map();
144
- for (const pair of spotMeta.universe) {
145
- for (const tokenIndex of pair.tokens) {
146
- if (!pairByTokenIndex.has(tokenIndex)) {
147
- pairByTokenIndex.set(tokenIndex, pair);
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 || req.token === "USDC")
447
+ if (req.shortfall <= 0)
154
448
  continue;
155
- // Move USDC from perp → spot
156
- const transferAmount = req.shortfall * 1.01; // 1% buffer for slippage
157
- this.logger.info({ amount: transferAmount, token: req.token }, "Transferring USDC to spot for swap");
158
- await this.provider.usdClassTransfer(transferAmount, false);
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 spotBook = await this.provider.l2Book(req.token);
162
- if (spotBook.levels[1].length === 0) {
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
- // Use best ask with slippage for limit price
171
- const bestAsk = parseFloat(spotBook.levels[1][0].px);
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: `Spot token ${req.token} not found in spotMeta`,
482
+ error: `Invalid spot top-of-book price for ${req.token} (${market.pairLabel})`,
180
483
  };
181
484
  }
182
- // Spot asset index = 10000 + 2 * pair_index for the base token
183
- // Find the pair that has this token
184
- const pair = pairByTokenIndex.get(spotToken.index);
185
- if (!pair) {
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: `No spot pair found for ${req.token}`,
500
+ error: `Invalid spot order size for ${req.token} (${market.pairLabel})`,
191
501
  };
192
502
  }
193
- const spotAssetIndex = 10000 + 2 * pair.index;
194
- this.logger.info({ token: req.token, size: req.shortfall, limitPrice, spotAssetIndex }, "Placing spot swap order");
195
- const result = await this.provider.placeOrder({
196
- assetIndex: spotAssetIndex,
197
- isBuy: true,
198
- price: limitPrice,
199
- size: req.shortfall.toString(),
200
- reduceOnly: false,
201
- orderType: { limit: { tif: "Ioc" } },
202
- });
203
- const status = result.statuses[0];
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
- if (status && typeof status === "object" && "filled" in status) {
206
- filled = status.filled.totalSz;
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: req.swapFrom,
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,