perp-cli 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/commands/account.js +2 -2
  19. package/dist/commands/arb/index.d.ts +20 -0
  20. package/dist/commands/arb/index.js +121 -36
  21. package/dist/commands/arb-auto.d.ts +36 -0
  22. package/dist/commands/arb-auto.js +202 -76
  23. package/dist/commands/arb.js +51 -35
  24. package/dist/commands/bot.d.ts +6 -0
  25. package/dist/commands/bot.js +55 -45
  26. package/dist/commands/funds-onchain.js +6 -5
  27. package/dist/commands/funds.js +18 -5
  28. package/dist/commands/history.js +135 -53
  29. package/dist/commands/portfolio.d.ts +5 -0
  30. package/dist/commands/portfolio.js +58 -20
  31. package/dist/commands/rebalance.js +46 -10
  32. package/dist/commands/settings.js +2 -1
  33. package/dist/commands/trade.js +8 -4
  34. package/dist/commands/wallet.js +1 -1
  35. package/dist/cross-chain-margin.d.ts +29 -2
  36. package/dist/cross-chain-margin.js +30 -4
  37. package/dist/dashboard/server.d.ts +45 -8
  38. package/dist/dashboard/server.js +113 -42
  39. package/dist/dashboard/ui.js +46 -27
  40. package/dist/dashboard/ws-feeds.d.ts +25 -3
  41. package/dist/dashboard/ws-feeds.js +219 -82
  42. package/dist/dex-asset-map.d.ts +23 -4
  43. package/dist/dex-asset-map.js +39 -13
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.d.ts +6 -0
  46. package/dist/exchanges/arcus/public.js +12 -4
  47. package/dist/exchanges/aster.d.ts +19 -7
  48. package/dist/exchanges/aster.js +79 -46
  49. package/dist/exchanges/hyperliquid.d.ts +30 -0
  50. package/dist/exchanges/hyperliquid.js +159 -34
  51. package/dist/exchanges/lighter.d.ts +1 -1
  52. package/dist/exchanges/lighter.js +11 -9
  53. package/dist/exchanges/ondo/funds.d.ts +0 -1
  54. package/dist/exchanges/pacifica.d.ts +7 -0
  55. package/dist/exchanges/pacifica.js +52 -26
  56. package/dist/rebalance.d.ts +20 -1
  57. package/dist/rebalance.js +31 -5
  58. package/dist/strategies/funding-arb.js +38 -7
  59. package/package.json +1 -1
  60. package/skills/perp-cli/SKILL.md +2 -2
  61. package/skills/perp-cli/references/commands.md +5 -3
@@ -54,16 +54,22 @@ class FundingAutoStrategy {
54
54
  get defaultFeeRate() { return Number(this._config.defaultFeeRate ?? 0.0005); }
55
55
  get defaultSlippage() { return Number(this._config.defaultSlippage ?? 0.001); }
56
56
  get scanIntervalTicks() { return Number(this._config.scanIntervalTicks ?? 1); }
57
- /** Position size: min of sizeUsd (if set) and maxCapitalPct% of total equity */
58
- _cachedEquity = 0;
57
+ /** Total equity across all venues, read this tick; null = some venue's balance is unknown. */
58
+ _cachedEquity = null;
59
+ /**
60
+ * Position size: min of sizeUsd (if set) and maxCapitalPct% of total equity. null = cannot be
61
+ * sized (equity unknown, or no capital and no sizeUsd) — no entry, never a substituted size.
62
+ */
59
63
  getSizeUsd() {
64
+ if (this._cachedEquity === null)
65
+ return null;
60
66
  const fixedSize = Number(this._config.sizeUsd ?? 0);
61
67
  const capitalLimit = this._cachedEquity * (this.maxCapitalPct / 100);
62
68
  if (fixedSize > 0 && capitalLimit > 0)
63
69
  return Math.min(fixedSize, capitalLimit);
64
70
  if (capitalLimit > 0)
65
71
  return capitalLimit;
66
- return fixedSize > 0 ? fixedSize : 100; // fallback
72
+ return null;
67
73
  }
68
74
  describe() {
69
75
  return {
@@ -96,22 +102,27 @@ class FundingAutoStrategy {
96
102
  ctx.log(`[funding-auto] Starting (perp-perp only; spot-perp mode is not supported by this strategy)`);
97
103
  }
98
104
  async onTick(ctx, _snapshot) {
99
- // Cache total equity across ALL exchanges for capital-based sizing
100
- try {
101
- const adapters = this.getAdapters(ctx);
102
- let totalEquity = 0;
103
- for (const [, adapter] of adapters) {
104
- try {
105
- const bal = await adapter.getBalance();
106
- totalEquity += Number(bal.equity) || 0;
107
- }
108
- catch { /* skip */ }
109
- }
110
- this._cachedEquity = totalEquity > 0 ? totalEquity : this._cachedEquity;
111
- }
112
- catch { /* keep previous cached value */ }
113
105
  // Get all adapters
114
106
  const adapters = this.getAdapters(ctx);
107
+ // Total equity across ALL exchanges for capital-based sizing, read fresh every tick.
108
+ // Any unknown balance → equity unknown (null): no new entry is sized this tick (never a
109
+ // stale value, a venue counted as $0, or a substituted size).
110
+ let totalEquity = 0;
111
+ for (const [name, adapter] of adapters) {
112
+ try {
113
+ const bal = await adapter.getBalance();
114
+ const eq = Number(bal.equity);
115
+ if (!Number.isFinite(eq))
116
+ throw new Error(`equity "${bal.equity}" is not a number`);
117
+ if (totalEquity !== null)
118
+ totalEquity += eq;
119
+ }
120
+ catch (e) {
121
+ totalEquity = null;
122
+ ctx.log(`[funding-auto] ${name} balance unreadable (${e instanceof Error ? e.message : String(e)}) — equity unknown, no new entries this tick`);
123
+ }
124
+ }
125
+ this._cachedEquity = totalEquity;
115
126
  if (adapters.size < 2) {
116
127
  ctx.log(`[funding-auto] Need 2+ exchanges, have ${adapters.size}. Skipping.`);
117
128
  return [];
@@ -123,6 +134,8 @@ class FundingAutoStrategy {
123
134
  // Fetch funding rates from all exchanges (price data cached for position sizing)
124
135
  const ratesByExchange = new Map();
125
136
  const priceCache = new Map(); // "exchange:symbol" -> price
137
+ // Venues whose markets could not be read this tick: no open / close decision touches them.
138
+ const ratesUnavailable = new Set();
126
139
  for (const [name, adapter] of adapters) {
127
140
  try {
128
141
  const markets = await adapter.getMarkets();
@@ -144,17 +157,19 @@ class FundingAutoStrategy {
144
157
  }
145
158
  ratesByExchange.set(name, map);
146
159
  }
147
- catch {
148
- // Skip unavailable exchange
160
+ catch (e) {
161
+ ratesUnavailable.add(name);
162
+ ctx.log(`[funding-auto] ${name} markets unreadable (${e instanceof Error ? e.message : String(e)}) — no open / close decision on ${name} this tick`);
149
163
  }
150
164
  }
151
165
  // Store priceCache for use by openPosition/closePosition
152
166
  ctx.state.set("priceCache", priceCache);
153
167
  // Calculate notional for discovered positions (using current price)
168
+ // (unknown price → notional left unset, never valued at $0)
154
169
  for (const pos of positions) {
155
170
  const price = priceCache.get(`${pos.longExchange}:${pos.symbol}`)
156
- || priceCache.get(`${pos.shortExchange}:${pos.symbol}`) || 0;
157
- if (price > 0) {
171
+ ?? priceCache.get(`${pos.shortExchange}:${pos.symbol}`);
172
+ if (price !== undefined) {
158
173
  pos.notional = Math.max(pos.longSize, pos.shortSize) * price;
159
174
  }
160
175
  }
@@ -171,12 +186,15 @@ class FundingAutoStrategy {
171
186
  const longRates = ratesByExchange.get(pos.longExchange);
172
187
  const shortRates = ratesByExchange.get(pos.shortExchange);
173
188
  if (!longRates || !shortRates) {
189
+ const down = [pos.longExchange, pos.shortExchange].filter(n => ratesUnavailable.has(n) || !ratesByExchange.has(n));
190
+ ctx.log(`[funding-auto] ${pos.symbol}: rates unavailable on ${down.join(", ")} — skipping the exit decision this tick`);
174
191
  surviving.push(pos);
175
192
  continue;
176
193
  }
177
194
  const longInfo = longRates.get(pos.symbol);
178
195
  const shortInfo = shortRates.get(pos.symbol);
179
196
  if (!longInfo || !shortInfo) {
197
+ ctx.log(`[funding-auto] ${pos.symbol}: market missing on ${!longInfo ? pos.longExchange : pos.shortExchange} — skipping the exit decision this tick`);
180
198
  surviving.push(pos);
181
199
  continue;
182
200
  }
@@ -184,6 +202,11 @@ class FundingAutoStrategy {
184
202
  // (each normalized by its own funding basis), annualized %. Positive = still
185
203
  // earning; ≤ 0 = the pair has reversed and now pays.
186
204
  const currentSpread = annualizeHourlyRate(hourlyOf(shortInfo, pos.shortExchange) - hourlyOf(longInfo, pos.longExchange));
205
+ if (!Number.isFinite(currentSpread)) {
206
+ ctx.log(`[funding-auto] ${pos.symbol}: funding rate unknown (${pos.longExchange}↔${pos.shortExchange}) — skipping the exit decision this tick`);
207
+ surviving.push(pos);
208
+ continue;
209
+ }
187
210
  // Calculate real P&L from exchange history. An unreadable history is never "0":
188
211
  // no close decision for this pair this tick.
189
212
  const [longPnl, shortPnl] = await Promise.all([
@@ -219,8 +242,14 @@ class FundingAutoStrategy {
219
242
  return surviving.length > 0 ? [{ type: "noop" }] : [];
220
243
  if (surviving.length >= this.maxPositions)
221
244
  return [{ type: "noop" }];
245
+ // Unknown sizing (equity unknown, or no capital and no sizeUsd): no new entries this tick.
246
+ const sizeUsd = this.getSizeUsd();
247
+ if (sizeUsd === null) {
248
+ ctx.log(`[funding-auto] No new entries: position size unknown (equity ${this._cachedEquity === null ? "unknown" : `$${this._cachedEquity.toFixed(2)}`}, sizeUsd ${Number(this._config.sizeUsd ?? 0)})`);
249
+ return surviving.length > 0 ? [{ type: "noop" }] : [];
250
+ }
222
251
  ctx.state.set("lastScan", Date.now());
223
- ctx.log(`[funding-auto] Scanning ${ratesByExchange.size} exchanges, equity: $${this._cachedEquity.toFixed(2)}, size: $${this.getSizeUsd().toFixed(2)}/pos, active: ${surviving.length}`);
252
+ ctx.log(`[funding-auto] Scanning ${ratesByExchange.size} exchanges, equity: $${this._cachedEquity.toFixed(2)}, size: $${sizeUsd.toFixed(2)}/pos, active: ${surviving.length}`);
224
253
  const activeSymbols = new Set(surviving.map(p => `${p.symbol}:${p.mode}`));
225
254
  // Also block symbols we already tried to enter (prevents re-entry on failed/tiny orders)
226
255
  const attempted = ctx.state.get("attemptedSymbols") ?? new Set();
@@ -263,7 +292,7 @@ class FundingAutoStrategy {
263
292
  if (annualSpread < this.perpPerpMinSpread)
264
293
  continue;
265
294
  // Calculate cost and break-even
266
- const notional = this.getSizeUsd();
295
+ const notional = sizeUsd;
267
296
  const hourlyIncome = Math.abs(maxRate - minRate) * notional;
268
297
  const { totalCost } = calculateCosts(notional, this.defaultFeeRate, this.defaultFeeRate, this.defaultSlippage, this.defaultSlippage);
269
298
  const breakEvenHours = calculateBreakEven(totalCost, hourlyIncome);
@@ -314,7 +343,7 @@ class FundingAutoStrategy {
314
343
  // Track attempted symbols to prevent re-entry on same tick cycle
315
344
  attempted.add(`${opp.symbol}:${opp.mode}`);
316
345
  ctx.state.set("attemptedSymbols", attempted);
317
- const success = await this.openPosition(ctx, opp, adapters);
346
+ const success = await this.openPosition(ctx, opp, adapters, sizeUsd);
318
347
  if (success) {
319
348
  currentCount++;
320
349
  perpPerpOpened++;
@@ -362,16 +391,10 @@ class FundingAutoStrategy {
362
391
  }
363
392
  return adapters;
364
393
  }
365
- /** Resolve cached price for a symbol on a given exchange */
394
+ /** Cached (this tick's) price for a symbol on a given exchange; undefined = unknown. */
366
395
  getCachedPrice(ctx, exchange, symbol) {
367
396
  const cache = ctx.state.get("priceCache");
368
- return cache?.get(`${exchange}:${symbol}`) ?? 0;
369
- }
370
- /** Format size with exchange-appropriate precision based on asset price */
371
- formatSize(rawSize, _symbol) {
372
- // Use price to determine precision
373
- const price = rawSize > 0 ? this.getSizeUsd() / rawSize : 0;
374
- return formatSizeForPrice(rawSize, price);
397
+ return cache?.get(`${exchange}:${symbol}`);
375
398
  }
376
399
  /** Discover existing arb pairs from live exchange positions; `unreadable` = venues whose positions failed to load. */
377
400
  async discoverArbPairs(adapters, log) {
@@ -458,7 +481,7 @@ class FundingAutoStrategy {
458
481
  * (reduce-only, verified). A definite short failure rolls the long back reduce-only.
459
482
  * An unknown outcome or a failed rollback / trim quarantines the symbol.
460
483
  */
461
- async openPosition(ctx, opp, adapters) {
484
+ async openPosition(ctx, opp, adapters, notionalUsd) {
462
485
  // Engine: equity unknown → no new entries (closes still run).
463
486
  if (ctx.state.get("equityUnknown") === true) {
464
487
  ctx.log(`[funding-auto] Skip ${opp.symbol}: equity unknown — no new entries`);
@@ -469,11 +492,12 @@ class FundingAutoStrategy {
469
492
  if (!longAdapter || !shortAdapter)
470
493
  return false;
471
494
  const price = this.getCachedPrice(ctx, opp.longExchange, opp.symbol)
472
- || this.getCachedPrice(ctx, opp.shortExchange, opp.symbol);
473
- if (price <= 0)
495
+ ?? this.getCachedPrice(ctx, opp.shortExchange, opp.symbol);
496
+ if (price === undefined) {
497
+ ctx.log(`[funding-auto] Skip ${opp.symbol}: price unknown on ${opp.longExchange} / ${opp.shortExchange}`);
474
498
  return false;
475
- const notionalUsd = this.getSizeUsd();
476
- const size = this.formatSize(notionalUsd / price, opp.symbol);
499
+ }
500
+ const size = formatSizeForPrice(notionalUsd / price, price);
477
501
  // Minimum notional check — skip if below exchange minimums
478
502
  if (notionalUsd < MIN_NOTIONAL_USD) {
479
503
  ctx.log(`[funding-auto] Skip ${opp.symbol}: notional $${notionalUsd.toFixed(0)} < min $${MIN_NOTIONAL_USD}`);
@@ -1,4 +1,6 @@
1
1
  import type { Strategy, StrategyContext, StrategyAction, EnrichedSnapshot } from "../strategy-types.js";
2
+ /** Consecutive failed open-order reads after which every tick logs a loud warning. */
3
+ export declare const GRID_READ_FAILURE_WARN = 5;
2
4
  export declare class GridStrategy implements Strategy {
3
5
  readonly name = "grid";
4
6
  describe(): {
@@ -1,4 +1,6 @@
1
1
  import { registerStrategy } from "../strategy-registry.js";
2
+ /** Consecutive failed open-order reads after which every tick logs a loud warning. */
3
+ export const GRID_READ_FAILURE_WARN = 5;
2
4
  export class GridStrategy {
3
5
  name = "grid";
4
6
  describe() {
@@ -95,12 +97,13 @@ export class GridStrategy {
95
97
  }
96
98
  // For each filled level, place the opposite (counter) order
97
99
  const newLevels = [...matchedLevels];
100
+ const counterOrders = [];
98
101
  for (const filled of unmatchedLevels) {
99
102
  const wasBuy = filled.side === "buy";
100
103
  const newSide = wasBuy ? "sell" : "buy";
101
104
  const newPrice = wasBuy ? filled.price + step : filled.price - step;
102
105
  if (newPrice >= gridLower && newPrice <= gridUpper) {
103
- actions.push({
106
+ counterOrders.push({
104
107
  type: "place_order",
105
108
  side: newSide,
106
109
  price: newPrice.toFixed(2),
@@ -111,8 +114,24 @@ export class GridStrategy {
111
114
  }
112
115
  }
113
116
  ctx.state.set("gridLevels", newLevels);
117
+ actions.push(...counterOrders);
118
+ const prevFailures = ctx.state.get("gridReadFailures") ?? 0;
119
+ if (prevFailures > 0)
120
+ ctx.log(` [GRID] Open orders readable again after ${prevFailures} failed read(s)`);
121
+ ctx.state.set("gridReadFailures", 0);
122
+ }
123
+ catch (e) {
124
+ // No fill detection / counter orders on an unread book (never on stale data); retried next tick.
125
+ const failures = (ctx.state.get("gridReadFailures") ?? 0) + 1;
126
+ ctx.state.set("gridReadFailures", failures);
127
+ const reason = e instanceof Error ? e.message : String(e);
128
+ if (failures >= GRID_READ_FAILURE_WARN) {
129
+ ctx.log(` [GRID] WARNING: open orders unreadable for ${failures} consecutive ticks (${reason}) — the grid is NOT placing counter orders; check: perp -e ${ctx.adapter.name} account orders`);
130
+ }
131
+ else {
132
+ ctx.log(` [GRID] Open orders read failed (${failures}/${GRID_READ_FAILURE_WARN}): ${reason} — fill detection skipped this tick`);
133
+ }
114
134
  }
115
- catch { /* retry next loop */ }
116
135
  // Auto-rebalance if price exits range
117
136
  if (params.rebalance) {
118
137
  const lastRebalance = ctx.state.get("lastRebalance") ?? 0;
@@ -12,7 +12,7 @@ import { registerStrategy } from "../strategy-registry.js";
12
12
  import { getFundingHours } from "../../funding.js";
13
13
  import { annualizeHourlyRate } from "../../funding/normalize.js";
14
14
  import { pruneExecutionLog } from "../../execution-log.js";
15
- import { buildAdapterMap, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols } from "./funding-arb-utils.js";
15
+ import { buildAdapterMap, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
16
16
  import { SpotPerpExecutor } from "./spot-perp-executor.js";
17
17
  import { findSpotQuoteBalance, getBotSpotAdapter, nonQuoteSpotBalances, spotNetworkOf, spotSharesPerpMargin, spotTokenBase } from "../../exchanges/spot-factory.js";
18
18
  import { getVenue } from "../../exchanges/venue.js";
@@ -84,6 +84,9 @@ export class SpotPerpArbStrategy {
84
84
  ctx.state.set("spaPositions", []);
85
85
  ctx.state.set("arbPositions", 0);
86
86
  ctx.state.set("fundingIncome", 0);
87
+ // Payments up to this instant are covered by the startup recovery (last 24 h); the periodic
88
+ // check books only payments newer than it (collectFundingSince high-water mark).
89
+ ctx.state.set("fundingStartTime", Date.now());
87
90
  ctx.state.set("fundingLastCheck", Date.now());
88
91
  ctx.log(` [SPA] Ready | rate >= ${p.min_rate}% | close < ${p.close_rate}% | size $${p.size_usd}`);
89
92
  ctx.log(` [SPA] Consistency >= ${(p.min_consistency * 100).toFixed(0)}% | history ${p.history_periods} periods | rotation ${p.rotation_threshold}%`);
@@ -103,13 +106,16 @@ export class SpotPerpArbStrategy {
103
106
  try {
104
107
  const payments = await a.getFundingPayments(200);
105
108
  const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
106
- const sum = payments.filter(fp => fp.time > oneDayAgo).reduce((s, fp) => s + parseFloat(fp.payment), 0);
109
+ const startedAt = ctx.state.get("fundingStartTime");
110
+ const sum = payments.filter(fp => fp.time > oneDayAgo && fp.time <= startedAt).reduce((s, fp) => s + parseFloat(fp.payment), 0);
107
111
  if (Math.abs(sum) > 0.001) {
108
112
  historicalFunding += sum;
109
113
  ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical`);
110
114
  }
111
115
  }
112
- catch { /* not supported */ }
116
+ catch (e) {
117
+ ctx.log(` [FUND] ${name}: funding history unavailable — historical funding excludes it: ${e instanceof Error ? e.message : String(e)}`);
118
+ }
113
119
  }
114
120
  if (Math.abs(historicalFunding) > 0.001)
115
121
  ctx.state.set("fundingIncome", historicalFunding);
@@ -181,22 +187,15 @@ export class SpotPerpArbStrategy {
181
187
  const lastCheck = ctx.state.get("fundingLastCheck");
182
188
  if (Date.now() - lastCheck > 5 * 60 * 1000) {
183
189
  try {
184
- let total = 0;
185
- for (const [name, a] of adapters) {
186
- try {
187
- const payments = await a.getFundingPayments(50);
188
- const start = ctx.state.get("fundingLastCheck");
189
- const sum = payments.filter(fp => fp.time > start - 5 * 60 * 1000).reduce((s, fp) => s + parseFloat(fp.payment), 0);
190
- total += sum;
191
- if (Math.abs(sum) > 0.001)
192
- ctx.log(` [FUND] ${name}: $${sum.toFixed(4)}`);
193
- }
194
- catch { /* skip */ }
195
- }
190
+ const since = ctx.state.get("fundingLastCheckByVenue") ?? {};
191
+ const { total } = await collectFundingSince(adapters, since, ctx.state.get("fundingStartTime"), ctx.log);
192
+ ctx.state.set("fundingLastCheckByVenue", since);
196
193
  ctx.state.set("fundingIncome", ctx.state.get("fundingIncome") + total);
197
194
  ctx.state.set("fundingLastCheck", Date.now());
198
195
  }
199
- catch { /* non-critical */ }
196
+ catch (e) {
197
+ ctx.log(` [FUND] funding check failed: ${e instanceof Error ? e.message : String(e)}`);
198
+ }
200
199
  }
201
200
  for (const pos of positions) {
202
201
  if (Date.now() - pos.lastFundingCheck < 10 * 60 * 1000)
@@ -213,7 +212,10 @@ export class SpotPerpArbStrategy {
213
212
  pos.fundingHistory = recent.map(fp => ({ time: fp.time, amount: parseFloat(fp.payment) }));
214
213
  pos.lastFundingCheck = Date.now();
215
214
  }
216
- catch { /* non-critical */ }
215
+ catch (e) {
216
+ // lastFundingCheck is not advanced: retried next tick.
217
+ ctx.log(` [FUND] ${pos.symbol} ${pos.perpExchange}: funding history unavailable: ${e instanceof Error ? e.message : String(e)}`);
218
+ }
217
219
  }
218
220
  const display = positions.map(pos => {
219
221
  const ann = this.calcAnnualized(pos, ratesByExchange);
@@ -589,11 +591,17 @@ export class SpotPerpArbStrategy {
589
591
  const maxTotalPositionUsd = riskConfig?.max_position_usd ?? Infinity;
590
592
  const positions = ctx.state.get("spaPositions");
591
593
  let currentTotalUsd = 0;
592
- for (const pos of positions) {
594
+ // No cap (Infinity): exposure does not gate the entry.
595
+ for (const pos of Number.isFinite(maxTotalPositionUsd) ? positions : []) {
593
596
  const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
594
597
  ?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
595
- const price = ri?.price ?? 0;
596
- currentTotalUsd += price * parseFloat(pos.size);
598
+ // Unknown price of a held position → exposure unknown: no new entry this cycle (a $0
599
+ // valuation would understate exposure and let the max_position_usd cap be bypassed).
600
+ if (ri === undefined || !(ri.price > 0)) {
601
+ ctx.log(` [SPA] Skip ${symbol}: ${pos.symbol} ${pos.perpExchange} price unknown this cycle — total exposure unknown, no new entries`);
602
+ return false;
603
+ }
604
+ currentTotalUsd += ri.price * parseFloat(pos.size);
597
605
  }
598
606
  const remainingBudget = maxTotalPositionUsd - currentTotalUsd;
599
607
  if (remainingBudget < 10) {
@@ -1,7 +1,14 @@
1
1
  export interface ExchangeBalance {
2
2
  exchange: string;
3
- equity: string;
4
- available: string;
3
+ /** null = the balance read failed (`error` says why) — shown "unavailable", never $0. */
4
+ equity: string | null;
5
+ available: string | null;
6
+ error?: string;
7
+ }
8
+ /** A venue whose display read (positions / spot balances) failed this tick. */
9
+ export interface VenueReadError {
10
+ exchange: string;
11
+ error: string;
5
12
  }
6
13
  export interface BotTuiState {
7
14
  phase: string;
@@ -29,6 +36,10 @@ export interface BotTuiState {
29
36
  strategyState: Record<string, unknown>;
30
37
  tick: number;
31
38
  exchangeBalances?: ExchangeBalance[];
39
+ /** Venues whose positions could not be read: their positions are unknown, not empty. */
40
+ positionErrors?: VenueReadError[];
41
+ /** Open orders read failure (null = read): the orders are unknown, not empty. */
42
+ openOrdersError?: string | null;
32
43
  }
33
44
  export interface Position {
34
45
  symbol: string;
@@ -68,15 +68,20 @@ export function BotDashboard({ initialState, onQuit, onPause, subscribe }) {
68
68
  const drawdown = state.equity !== null && state.peakEquity !== null ? state.peakEquity - state.equity : null;
69
69
  const drawdownPct = drawdown !== null && state.peakEquity !== null && state.peakEquity > 0 ? (drawdown / state.peakEquity) * 100 : 0;
70
70
  const usd = (v) => v === null ? "unknown" : `$${v.toFixed(2)}`;
71
- const totalEquity = state.exchangeBalances !== undefined ? state.exchangeBalances.reduce((s, e) => s + parseFloat(e.equity), 0) : state.equity;
71
+ // Any unavailable venue balance → the total is unknown (never summed as if that venue were $0).
72
+ const totalEquity = state.exchangeBalances !== undefined
73
+ ? (state.exchangeBalances.some((e) => e.equity === null) ? null : state.exchangeBalances.reduce((s, e) => s + parseFloat(e.equity), 0))
74
+ : state.equity;
75
+ const positionErrors = state.positionErrors ?? [];
76
+ const openOrdersError = state.openOrdersError ?? null;
72
77
  const bidOrders = state.openOrders.filter((o) => o.side.toLowerCase() === "buy");
73
78
  const askOrders = state.openOrders.filter((o) => o.side.toLowerCase() === "sell");
74
79
  const hasMultiExchange = (state.exchangeBalances?.length ?? 0) > 1;
75
80
  const isMultiSymbol = state.symbol.toUpperCase() === "ALL";
76
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.strategy, " | ", state.symbol, " | ", state.exchange] }), _jsxs(Text, { color: "gray", children: ["Runtime: ", formatDuration(state.runtime), " | Tick: ", state.tick] })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: phaseColor(state.phase), children: phaseIndicator(state.phase) }) })] }), isMultiSymbol ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Exchanges: ", _jsx(Text, { bold: true, color: "white", children: state.exchangeBalances?.length ?? 1 })] }), _jsxs(Text, { color: "gray", children: ["Positions: ", _jsx(Text, { bold: true, color: state.positions.length > 0 ? "yellow" : "white", children: state.positions.length })] }), _jsxs(Text, { color: "gray", children: ["Total Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(totalEquity) })] }), _jsxs(Text, { color: "gray", children: ["PnL: ", _jsxs(Text, { bold: true, color: state.totalPnl >= 0 ? "green" : "red", children: ["$", state.totalPnl.toFixed(2)] })] })] })) : (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Price: ", _jsxs(Text, { bold: true, color: "white", children: ["$", state.price.toFixed(2)] })] }), _jsxs(Text, { color: "gray", children: ["Vol24h: ", _jsxs(Text, { color: "white", children: [state.volatility24h.toFixed(1), "%"] })] }), _jsxs(Text, { color: "gray", children: ["FR: ", _jsxs(Text, { color: state.fundingRate >= 0 ? "green" : "red", children: [(state.fundingRate * 100).toFixed(4), "%"] })] }), _jsxs(Text, { color: "gray", children: ["OI: ", _jsx(Text, { color: "white", children: state.openInterest })] })] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Account" }), _jsxs(Text, { children: ["Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(state.equity) }), _jsxs(Text, { color: "gray", children: [" (peak: ", usd(state.peakEquity), ")"] })] }), _jsxs(Text, { children: ["Daily P&L: ", _jsxs(Text, { bold: true, color: pnlColor(state.dailyPnl), children: ["$", state.dailyPnl.toFixed(2)] })] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: ["Drawdown: ", _jsx(Text, { bold: true, color: drawdown === null ? "gray" : drawdown > 0 ? "red" : "green", children: drawdown === null ? "unknown" : `${usd(drawdown)} (${drawdownPct.toFixed(1)}%)` })] }), _jsxs(Text, { children: ["Orders: ", _jsx(Text, { bold: true, color: "white", children: state.ordersPlaced }), _jsx(Text, { color: "gray", children: " | Fills: " }), _jsx(Text, { bold: true, color: "white", children: state.fills }), _jsx(Text, { color: "gray", children: " | Total P&L: " }), _jsxs(Text, { bold: true, color: pnlColor(state.totalPnl), children: ["$", state.totalPnl.toFixed(2)] })] })] })] }), state.exchangeBalances && state.exchangeBalances.length > 1 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Exchange Balances" }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: pad("Exchange", 16) }), _jsx(Text, { color: "gray", children: pad("Equity", 14) }), _jsx(Text, { color: "gray", children: "Available" })] }), state.exchangeBalances.map((eb, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "white", children: pad(eb.exchange, 16) }), _jsx(Text, { color: "white", children: pad(`$${parseFloat(eb.equity).toFixed(2)}`, 14) }), _jsxs(Text, { color: "gray", children: ["$", parseFloat(eb.available).toFixed(2)] })] }, i)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Positions" }), state.positions.length === 0 ? (_jsx(Text, { color: "gray", children: " No open positions" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "gray", children: pad("Exchange", 14) }), _jsx(Text, { color: "gray", children: pad("Symbol", 10) }), _jsx(Text, { color: "gray", children: pad("Side", 6) }), _jsx(Text, { color: "gray", children: pad("Size", 12) }), _jsx(Text, { color: "gray", children: pad("Entry", 12) }), _jsx(Text, { color: "gray", children: pad("Mark", 12) }), _jsx(Text, { color: "gray", children: "uPnL" })] }), state.positions.map((p, i) => {
81
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.strategy, " | ", state.symbol, " | ", state.exchange] }), _jsxs(Text, { color: "gray", children: ["Runtime: ", formatDuration(state.runtime), " | Tick: ", state.tick] })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: phaseColor(state.phase), children: phaseIndicator(state.phase) }) })] }), isMultiSymbol ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Exchanges: ", _jsx(Text, { bold: true, color: "white", children: state.exchangeBalances?.length ?? 1 })] }), _jsxs(Text, { color: "gray", children: ["Positions: ", _jsx(Text, { bold: true, color: state.positions.length > 0 ? "yellow" : "white", children: state.positions.length })] }), _jsxs(Text, { color: "gray", children: ["Total Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(totalEquity) })] }), _jsxs(Text, { color: "gray", children: ["PnL: ", _jsxs(Text, { bold: true, color: state.totalPnl >= 0 ? "green" : "red", children: ["$", state.totalPnl.toFixed(2)] })] })] })) : (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Price: ", _jsxs(Text, { bold: true, color: "white", children: ["$", state.price.toFixed(2)] })] }), _jsxs(Text, { color: "gray", children: ["Vol24h: ", _jsx(Text, { color: "white", children: Number.isFinite(state.volatility24h) ? `${state.volatility24h.toFixed(1)}%` : "unknown" })] }), _jsxs(Text, { color: "gray", children: ["FR: ", _jsxs(Text, { color: state.fundingRate >= 0 ? "green" : "red", children: [(state.fundingRate * 100).toFixed(4), "%"] })] }), _jsxs(Text, { color: "gray", children: ["OI: ", _jsx(Text, { color: "white", children: state.openInterest })] })] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Account" }), _jsxs(Text, { children: ["Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(state.equity) }), _jsxs(Text, { color: "gray", children: [" (peak: ", usd(state.peakEquity), ")"] })] }), _jsxs(Text, { children: ["Daily P&L: ", _jsxs(Text, { bold: true, color: pnlColor(state.dailyPnl), children: ["$", state.dailyPnl.toFixed(2)] })] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: ["Drawdown: ", _jsx(Text, { bold: true, color: drawdown === null ? "gray" : drawdown > 0 ? "red" : "green", children: drawdown === null ? "unknown" : `${usd(drawdown)} (${drawdownPct.toFixed(1)}%)` })] }), _jsxs(Text, { children: ["Orders: ", _jsx(Text, { bold: true, color: "white", children: state.ordersPlaced }), _jsx(Text, { color: "gray", children: " | Fills: " }), _jsx(Text, { bold: true, color: "white", children: state.fills }), _jsx(Text, { color: "gray", children: " | Total P&L: " }), _jsxs(Text, { bold: true, color: pnlColor(state.totalPnl), children: ["$", state.totalPnl.toFixed(2)] })] })] })] }), state.exchangeBalances && state.exchangeBalances.length > 1 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Exchange Balances" }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: pad("Exchange", 16) }), _jsx(Text, { color: "gray", children: pad("Equity", 14) }), _jsx(Text, { color: "gray", children: "Available" })] }), state.exchangeBalances.map((eb, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "white", children: pad(eb.exchange, 16) }), eb.equity === null ? (_jsxs(Text, { color: "red", children: ["unavailable", eb.error ? ` (${eb.error})` : ""] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "white", children: pad(`$${parseFloat(eb.equity).toFixed(2)}`, 14) }), _jsx(Text, { color: "gray", children: eb.available === null ? "unavailable" : `$${parseFloat(eb.available).toFixed(2)}` })] }))] }, i)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Positions" }), positionErrors.map((pe, i) => (_jsxs(Text, { color: "red", children: [" ", pe.exchange, ": unavailable (", pe.error, ")"] }, `err-${i}`))), state.positions.length === 0 ? (positionErrors.length === 0 && _jsx(Text, { color: "gray", children: " No open positions" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "gray", children: pad("Exchange", 14) }), _jsx(Text, { color: "gray", children: pad("Symbol", 10) }), _jsx(Text, { color: "gray", children: pad("Side", 6) }), _jsx(Text, { color: "gray", children: pad("Size", 12) }), _jsx(Text, { color: "gray", children: pad("Entry", 12) }), _jsx(Text, { color: "gray", children: pad("Mark", 12) }), _jsx(Text, { color: "gray", children: "uPnL" })] }), state.positions.map((p, i) => {
77
82
  const upnl = parseFloat(p.unrealizedPnl);
78
83
  return (_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "cyan", children: pad(p.exchange ?? "—", 14) }), _jsx(Text, { color: "white", children: pad(p.symbol, 10) }), _jsx(Text, { color: p.side.toLowerCase() === "long" ? "green" : "red", children: pad(p.side, 6) }), _jsx(Text, { color: "white", children: pad(p.size, 12) }), _jsx(Text, { color: "white", children: pad(p.entryPrice, 12) }), _jsx(Text, { color: "white", children: pad(p.markPrice, 12) }), _jsx(Text, { color: isNaN(upnl) ? "gray" : pnlColor(upnl), children: isNaN(upnl) ? "$—" : `$${upnl.toFixed(2)}` })] }, i));
79
- })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "green", bold: true, children: ["Bids (", bidOrders.length, ")"] }), bidOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (bidOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "green", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] }), _jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "red", bold: true, children: ["Asks (", askOrders.length, ")"] }), askOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (askOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "red", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: state.strategyState.fundingTotal !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Funding & Arb" }), _jsxs(Box, { flexDirection: "row", marginLeft: 2, children: [_jsx(Text, { color: "gray", children: "Funding Income: " }), _jsx(Text, { color: "white", children: String(state.strategyState.fundingTotal) }), _jsx(Text, { color: "gray", children: " Positions: " }), _jsx(Text, { color: "white", children: String(state.strategyState.arbPositions ?? 0) })] }), Array.isArray(state.strategyState.arbPositionDetails) &&
84
+ })] }))] }), openOrdersError !== null ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Open orders unavailable (", openOrdersError, ")"] }) })) : (_jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "green", bold: true, children: ["Bids (", bidOrders.length, ")"] }), bidOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (bidOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "green", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] }), _jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "red", bold: true, children: ["Asks (", askOrders.length, ")"] }), askOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (askOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "red", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] })] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: state.strategyState.fundingTotal !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Funding & Arb" }), _jsxs(Box, { flexDirection: "row", marginLeft: 2, children: [_jsx(Text, { color: "gray", children: "Funding Income: " }), _jsx(Text, { color: "white", children: String(state.strategyState.fundingTotal) }), _jsx(Text, { color: "gray", children: " Positions: " }), _jsx(Text, { color: "white", children: String(state.strategyState.arbPositions ?? 0) })] }), Array.isArray(state.strategyState.arbPositionDetails) &&
80
85
  state.strategyState.arbPositionDetails.map((detail, i) => (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "yellow", children: detail }) }, i)))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Strategy State" }), _jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [Object.entries(state.strategyState)
81
86
  .filter(([k, v]) => {
82
87
  if (k === 'arbRunning' || k === 'fundingLastCheck' || k === 'fundingHistory' || k === 'arbOpenPositions')
@@ -65,6 +65,8 @@ export declare function getEvmUsdcBalance(chain: string, address: string): Promi
65
65
  * Returns balance in USDC (human-readable).
66
66
  */
67
67
  export declare function getSolanaUsdcBalance(ownerPubkey: string): Promise<number>;
68
+ /** RPC "account not found" for getTokenAccountBalance (the ATA was never created ⇒ balance 0). */
69
+ export declare function isSolanaAccountNotFound(e: unknown): boolean;
68
70
  /**
69
71
  * Check USDC balance for a bridge source chain + address.
70
72
  */
@@ -318,13 +318,32 @@ export async function getSolanaUsdcBalance(ownerPubkey) {
318
318
  const usdcMint = new PublicKey(USDC_ADDRESSES.solana);
319
319
  const tokenProgram = new PublicKey(SPL_TOKEN_PROGRAM);
320
320
  const [ata] = PublicKey.findProgramAddressSync([owner.toBuffer(), tokenProgram.toBuffer(), usdcMint.toBuffer()], new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
321
+ let info;
321
322
  try {
322
- const info = await connection.getTokenAccountBalance(ata);
323
- return Number(info.value.uiAmount ?? 0);
323
+ info = await connection.getTokenAccountBalance(ata);
324
324
  }
325
- catch {
326
- return 0; // ATA doesn't exist
325
+ catch (e) {
326
+ // Only a missing USDC token account is a real 0 balance. Any other RPC failure is unknown:
327
+ // reporting it as 0 would read as "no USDC" (or, inverted, mask a balance check).
328
+ if (isSolanaAccountNotFound(e))
329
+ return 0;
330
+ throw new PerpError("EXCHANGE_UNREACHABLE", `Solana USDC balance of ${ownerPubkey} could not be read: ${e instanceof Error ? e.message : String(e)}`, {
331
+ remediation: "Retry, or set a working Solana RPC (settings rpc.solana) — the balance is unknown, not 0",
332
+ });
333
+ }
334
+ // Raw amount / decimals (uiAmount may be null) — a malformed answer is an error, not 0.
335
+ const raw = Number(info.value.amount);
336
+ if (!Number.isFinite(raw) || !Number.isInteger(info.value.decimals)) {
337
+ throw new PerpError("EXCHANGE_ERROR", `Solana RPC returned a malformed USDC balance for ${ownerPubkey}: ${JSON.stringify(info.value)}`, {
338
+ remediation: "Retry, or set a different Solana RPC (settings rpc.solana)",
339
+ });
327
340
  }
341
+ return raw / 10 ** info.value.decimals;
342
+ }
343
+ /** RPC "account not found" for getTokenAccountBalance (the ATA was never created ⇒ balance 0). */
344
+ export function isSolanaAccountNotFound(e) {
345
+ const msg = e instanceof Error ? e.message : String(e);
346
+ return /could not find account|account not found|AccountNotFound/i.test(msg);
328
347
  }
329
348
  /**
330
349
  * Check USDC balance for a bridge source chain + address.
@@ -412,38 +431,43 @@ function encodeHyperCoreHookData(recipientAddress, dexId = HYPERCORE_DEX_PERPS)
412
431
  * Get CCTP fees for a Solana/EVM → HyperCore transfer.
413
432
  */
414
433
  async function getHyperCoreCctpFees(srcDomain, amount) {
434
+ // SSOT rule #2 (same as getCctpRelayFee): the burn's maxFee comes from Circle's fee schedule.
435
+ // A hardcoded default (formerly 1bp + $0.20) could under-fee (transfer stuck) or over-fee —
436
+ // so an unreadable schedule is an error, never a silently-used guess.
415
437
  const url = `${HYPERCORE_FEES_API}/${srcDomain}/${HYPERCORE_CCTP_DOMAIN}?forward=true&hyperCoreDeposit=true`;
438
+ const fail = (why) => new PerpError("EXCHANGE_UNREACHABLE", `CCTP HyperCore fee schedule ${srcDomain}→${HYPERCORE_CCTP_DOMAIN} unavailable: ${why} — nothing was sent`, {
439
+ remediation: "Retry shortly (Circle iris-api); the bridge fee cannot be set without it",
440
+ });
441
+ let res;
416
442
  try {
417
- const res = await fetch(url);
418
- if (res.ok) {
419
- // API returns array: [{finalityThreshold, minimumFee, forwardFee: {low, med, high}}, ...]
420
- const schedules = await res.json();
421
- // Use fast transfer schedule (threshold=1000) if available, else first
422
- const fast = schedules.find(s => s.finalityThreshold === 1000) ?? schedules[0];
423
- if (fast) {
424
- const bps = Number(fast.feeRate ?? 1); // basis points, default 1bp
425
- const forwardFee = Number(fast.forwardFee?.med ?? fast.forwardFee?.low ?? 200000); // subunits
426
- const protocolFee = Math.ceil(amount * 1e6 * bps / 10000);
427
- return {
428
- protocolFee: protocolFee / 1e6,
429
- forwardingFee: forwardFee / 1e6,
430
- totalFee: (protocolFee + forwardFee) / 1e6,
431
- maxFee: protocolFee + forwardFee, // in subunits for instruction
432
- };
433
- }
434
- }
443
+ res = await fetch(url);
435
444
  }
436
- catch {
437
- // fallback to defaults
445
+ catch (e) {
446
+ throw fail(e instanceof Error ? e.message : String(e));
438
447
  }
439
- // Default: 1bp + $0.20
440
- const protocolFee = Math.ceil(amount * 1e6 / 10000);
441
- const forwardingFee = 200000;
448
+ if (!res.ok)
449
+ throw fail(`HTTP ${res.status}`);
450
+ // API returns array: [{finalityThreshold, minimumFee, forwardFee: {low, med, high}}, ...]
451
+ const schedules = await res.json().catch((e) => { throw fail(`invalid JSON (${e instanceof Error ? e.message : String(e)})`); });
452
+ if (!Array.isArray(schedules))
453
+ throw fail(`unexpected response ${JSON.stringify(schedules).slice(0, 200)}`);
454
+ // Use fast transfer schedule (threshold=1000) if available, else the first published tier
455
+ const fast = schedules.find(s => s.finalityThreshold === 1000) ?? schedules[0];
456
+ if (!fast)
457
+ throw fail("no fee schedule");
458
+ // Protocol fee rate in basis points: feeRate when published, else minimumFee (Circle's bps field).
459
+ const bps = Number(fast.feeRate ?? fast.minimumFee);
460
+ const forwardFee = Number(fast.forwardFee?.med ?? fast.forwardFee?.low); // subunits
461
+ if (!Number.isFinite(bps) || bps < 0)
462
+ throw fail("schedule without a protocol fee rate");
463
+ if (!Number.isFinite(forwardFee) || forwardFee < 0)
464
+ throw fail("schedule without a forwarding fee");
465
+ const protocolFee = Math.ceil(amount * 1e6 * bps / 10000);
442
466
  return {
443
467
  protocolFee: protocolFee / 1e6,
444
- forwardingFee: 0.20,
445
- totalFee: (protocolFee + forwardingFee) / 1e6,
446
- maxFee: protocolFee + forwardingFee,
468
+ forwardingFee: forwardFee / 1e6,
469
+ totalFee: (protocolFee + forwardFee) / 1e6,
470
+ maxFee: protocolFee + forwardFee, // in subunits for instruction
447
471
  };
448
472
  }
449
473
  // ── deBridge DLN API ──
@@ -1713,10 +1737,23 @@ export async function getAllQuotes(srcChain, dstChain, amount, senderAddress, re
1713
1737
  getDebridgeQuote(srcChain, dstChain, amount, senderAddress, recipientAddress),
1714
1738
  ]);
1715
1739
  const quotes = [];
1716
- for (const r of results) {
1740
+ const failures = [];
1741
+ const providers = ["cctp", "relay", "debridge"];
1742
+ results.forEach((r, i) => {
1717
1743
  if (r.status === "fulfilled")
1718
1744
  quotes.push(r.value);
1745
+ else
1746
+ failures.push(`${providers[i]}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
1747
+ });
1748
+ // A provider without this route is normal; still say which ones were left out and why, and
1749
+ // never answer "no quotes" without the reasons.
1750
+ if (quotes.length === 0) {
1751
+ throw new PerpError("NO_BRIDGE_ROUTE", `No bridge quote for ${srcChain} → ${dstChain} (${failures.join("; ")})`, {
1752
+ remediation: "Check the chains / amount, or retry if a provider was unreachable",
1753
+ });
1719
1754
  }
1755
+ if (failures.length > 0)
1756
+ process.stderr.write(`[bridge] providers without a quote: ${failures.join("; ")}\n`);
1720
1757
  // Price native fees (deBridge fixFee) so quotes compare on the same basis; an unpriced native
1721
1758
  // fee ranks that quote last (its total cost is unknown), never as free.
1722
1759
  const symbols = [...new Set(quotes.flatMap(q => (q.nativeFee ? [q.nativeFee.symbol] : [])))];
@@ -288,7 +288,7 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
288
288
  });
289
289
  account
290
290
  .command("history")
291
- .description("Order history")
291
+ .description("Order history (Aster: last 7 days, symbols with a position, open order or fill in that window)")
292
292
  .action(async () => {
293
293
  const adapter = await getAdapter();
294
294
  const orders = await adapter.getOrderHistory(30);
@@ -355,7 +355,7 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
355
355
  });
356
356
  account
357
357
  .command("trades")
358
- .description("Trade history (fills)")
358
+ .description("Trade history (fills) (Aster: last 7 days, symbols with a position, open order or fill in that window)")
359
359
  .action(async () => {
360
360
  const adapter = await getAdapter();
361
361
  const trades = await adapter.getTradeHistory(30);