perp-cli 0.14.0 → 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.
- package/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +16 -20
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +44 -0
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/cli-program.js +19 -2
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.d.ts +1 -1
- package/dist/commands/funds-onchain.js +12 -49
- package/dist/commands/funds.js +51 -52
- package/dist/commands/history.js +165 -62
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +74 -155
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +46 -27
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +33 -0
- package/dist/exchanges/aster.js +162 -55
- package/dist/exchanges/hyperliquid.d.ts +30 -0
- package/dist/exchanges/hyperliquid.js +159 -34
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/adapter.d.ts +4 -18
- package/dist/exchanges/ondo/adapter.js +5 -29
- package/dist/exchanges/ondo/funds.d.ts +1 -29
- package/dist/exchanges/ondo/funds.js +2 -94
- package/dist/exchanges/pacifica.d.ts +12 -0
- package/dist/exchanges/pacifica.js +78 -35
- package/dist/exchanges/qfex/adapter.js +4 -1
- package/dist/exchanges/venues/ondo.d.ts +0 -5
- package/dist/exchanges/venues/ondo.js +2 -6
- package/dist/exchanges/venues/qfex.d.ts +11 -0
- package/dist/exchanges/venues/qfex.js +10 -2
- package/dist/mcp-server.js +2 -2
- package/dist/onchain-balances.d.ts +40 -0
- package/dist/onchain-balances.js +173 -0
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +20 -3
- package/skills/perp-cli/references/agent-operations.md +2 -2
- package/skills/perp-cli/references/commands.md +12 -9
|
@@ -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
|
-
/**
|
|
58
|
-
_cachedEquity =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
if (price
|
|
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: $${
|
|
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 =
|
|
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
|
-
/**
|
|
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}`)
|
|
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
|
-
|
|
473
|
-
if (price
|
|
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
|
-
|
|
476
|
-
const size =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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: ",
|
|
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')
|
package/dist/bridge-engine.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/bridge-engine.js
CHANGED
|
@@ -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
|
-
|
|
323
|
-
return Number(info.value.uiAmount ?? 0);
|
|
323
|
+
info = await connection.getTokenAccountBalance(ata);
|
|
324
324
|
}
|
|
325
|
-
catch {
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
+
catch (e) {
|
|
446
|
+
throw fail(e instanceof Error ? e.message : String(e));
|
|
438
447
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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:
|
|
445
|
-
totalFee: (protocolFee +
|
|
446
|
-
maxFee: protocolFee +
|
|
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
|
-
|
|
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] : [])))];
|
package/dist/cli-program.js
CHANGED
|
@@ -195,8 +195,25 @@ export function buildProgram(opts) {
|
|
|
195
195
|
function getExchange() {
|
|
196
196
|
return resolveVenueId(program.opts().exchange);
|
|
197
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* The `--dex` value for the single `-e` venue. HIP-3 dexes exist only on Hyperliquid, so a
|
|
200
|
+
* `--dex` with another `-e` venue is refused instead of being dropped (the command would
|
|
201
|
+
* otherwise read / trade that venue's own markets while the user asked for a HIP-3 dex).
|
|
202
|
+
* Multi-venue commands (arb, portfolio) apply `--dex` to their Hyperliquid leg only and do
|
|
203
|
+
* not go through this check.
|
|
204
|
+
*/
|
|
205
|
+
function dexForExchange() {
|
|
206
|
+
const dex = program.opts().dex ?? null;
|
|
207
|
+
if (dex !== null && getExchange() !== "hyperliquid") {
|
|
208
|
+
const venue = program.getOptionValueSource("exchange") === "cli" ? `-e ${getExchange()}` : `the default exchange (${getExchange()})`;
|
|
209
|
+
throw new PerpError("INVALID_PARAMS", `--dex ${dex} is a Hyperliquid HIP-3 dex; ${venue} has no HIP-3 dexes`, {
|
|
210
|
+
remediation: `Use -e hyperliquid --dex ${dex}, or drop --dex for ${getExchange()}.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return dex;
|
|
214
|
+
}
|
|
198
215
|
async function getAdapter() {
|
|
199
|
-
_adapter = await createVenueAdapter(getExchange(),
|
|
216
|
+
_adapter = await createVenueAdapter(getExchange(), dexForExchange());
|
|
200
217
|
return _adapter;
|
|
201
218
|
}
|
|
202
219
|
// Sync wrapper for commands that need adapter (lazy init)
|
|
@@ -220,7 +237,7 @@ export function buildProgram(opts) {
|
|
|
220
237
|
}
|
|
221
238
|
// Public market-data adapters (read-only; no credential resolution).
|
|
222
239
|
async function getPublicAdapter() {
|
|
223
|
-
return createVenueAdapter(getExchange(),
|
|
240
|
+
return createVenueAdapter(getExchange(), dexForExchange(), true);
|
|
224
241
|
}
|
|
225
242
|
async function getPublicAdapterForExchange(rawExchange) {
|
|
226
243
|
return createVenueAdapter(rawExchange, program.opts().dex ?? null, true);
|