@exagent/agent 0.1.17 → 0.1.19
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/chunk-E5IE5WNT.mjs +4778 -0
- package/dist/chunk-IFPNUBXI.mjs +4666 -0
- package/dist/chunk-WDNMLAYM.mjs +4663 -0
- package/dist/chunk-XNP5C43A.mjs +4752 -0
- package/dist/chunk-ZLDWYPBF.mjs +4779 -0
- package/dist/cli.js +1818 -122
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +840 -4
- package/dist/index.d.ts +840 -4
- package/dist/index.js +1832 -109
- package/dist/index.mjs +25 -1
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -32,8 +32,9 @@ var path2 = __toESM(require("path"));
|
|
|
32
32
|
|
|
33
33
|
// src/runtime.ts
|
|
34
34
|
var import_sdk2 = require("@exagent/sdk");
|
|
35
|
-
var
|
|
36
|
-
var
|
|
35
|
+
var import_viem6 = require("viem");
|
|
36
|
+
var import_chains4 = require("viem/chains");
|
|
37
|
+
var import_accounts5 = require("viem/accounts");
|
|
37
38
|
|
|
38
39
|
// src/llm/openai.ts
|
|
39
40
|
var import_openai = __toESM(require("openai"));
|
|
@@ -893,6 +894,26 @@ var RelayConfigSchema = import_zod.z.object({
|
|
|
893
894
|
apiUrl: import_zod.z.string().url(),
|
|
894
895
|
heartbeatIntervalMs: import_zod.z.number().min(5e3).default(3e4)
|
|
895
896
|
}).optional();
|
|
897
|
+
var PerpConfigSchema = import_zod.z.object({
|
|
898
|
+
/** Enable perp trading */
|
|
899
|
+
enabled: import_zod.z.boolean().default(false),
|
|
900
|
+
/** Hyperliquid REST API URL */
|
|
901
|
+
apiUrl: import_zod.z.string().url().default("https://api.hyperliquid.xyz"),
|
|
902
|
+
/** Hyperliquid WebSocket URL */
|
|
903
|
+
wsUrl: import_zod.z.string().default("wss://api.hyperliquid.xyz/ws"),
|
|
904
|
+
/** Builder address for fee collection (must have >= 100 USDC on HL) */
|
|
905
|
+
builderAddress: import_zod.z.string(),
|
|
906
|
+
/** Builder fee in tenths of basis points (100 = 10 bps = 0.10%) */
|
|
907
|
+
builderFeeTenthsBps: import_zod.z.number().min(0).max(500).default(100),
|
|
908
|
+
/** Private key for the perp relayer (calls recordPerpTrade on Base). Falls back to agent wallet. */
|
|
909
|
+
perpRelayerKey: import_zod.z.string().optional(),
|
|
910
|
+
/** Maximum leverage per position (default: 10) */
|
|
911
|
+
maxLeverage: import_zod.z.number().min(1).max(50).default(10),
|
|
912
|
+
/** Maximum notional position size in USD (default: 50000) */
|
|
913
|
+
maxNotionalUSD: import_zod.z.number().min(100).default(5e4),
|
|
914
|
+
/** Allowed perp instruments (e.g. ["ETH", "BTC", "SOL"]). If empty, all instruments allowed. */
|
|
915
|
+
allowedInstruments: import_zod.z.array(import_zod.z.string()).optional()
|
|
916
|
+
}).optional();
|
|
896
917
|
var AgentConfigSchema = import_zod.z.object({
|
|
897
918
|
// Identity (from on-chain registration)
|
|
898
919
|
agentId: import_zod.z.union([import_zod.z.number().positive(), import_zod.z.string()]),
|
|
@@ -910,6 +931,8 @@ var AgentConfigSchema = import_zod.z.object({
|
|
|
910
931
|
vault: VaultConfigSchema.default({}),
|
|
911
932
|
// Relay configuration (command center)
|
|
912
933
|
relay: RelayConfigSchema,
|
|
934
|
+
// Perp trading configuration (Hyperliquid)
|
|
935
|
+
perp: PerpConfigSchema,
|
|
913
936
|
// Allowed tokens (addresses)
|
|
914
937
|
allowedTokens: import_zod.z.array(import_zod.z.string()).optional()
|
|
915
938
|
});
|
|
@@ -1465,6 +1488,101 @@ var RiskManager = class {
|
|
|
1465
1488
|
isLimitHit: pv > 0 ? this.dailyPnL < -maxLossUSD : false
|
|
1466
1489
|
};
|
|
1467
1490
|
}
|
|
1491
|
+
// ============================================================
|
|
1492
|
+
// PERP RISK FILTERING
|
|
1493
|
+
// ============================================================
|
|
1494
|
+
/**
|
|
1495
|
+
* Filter perp trade signals through risk checks.
|
|
1496
|
+
* Reduce-only signals (closes) always pass.
|
|
1497
|
+
*
|
|
1498
|
+
* @param signals - Raw perp signals from strategy
|
|
1499
|
+
* @param positions - Current open positions on Hyperliquid
|
|
1500
|
+
* @param account - Current account equity and margin
|
|
1501
|
+
* @param maxLeverage - Maximum allowed leverage
|
|
1502
|
+
* @param maxNotionalUSD - Maximum notional per position
|
|
1503
|
+
* @returns Signals that pass risk checks
|
|
1504
|
+
*/
|
|
1505
|
+
filterPerpSignals(signals, positions, account, maxLeverage, maxNotionalUSD) {
|
|
1506
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1507
|
+
if (today !== this.lastResetDate) {
|
|
1508
|
+
this.dailyPnL = 0;
|
|
1509
|
+
this.dailyFees = 0;
|
|
1510
|
+
this.lastResetDate = today;
|
|
1511
|
+
}
|
|
1512
|
+
if (this.isDailyLossLimitHit(account.totalEquity)) {
|
|
1513
|
+
console.warn("Daily loss limit reached \u2014 blocking new perp trades");
|
|
1514
|
+
return signals.filter((s) => s.reduceOnly);
|
|
1515
|
+
}
|
|
1516
|
+
return signals.filter((signal) => this.validatePerpSignal(signal, positions, account, maxLeverage, maxNotionalUSD));
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Validate an individual perp signal.
|
|
1520
|
+
*/
|
|
1521
|
+
validatePerpSignal(signal, positions, account, maxLeverage, maxNotionalUSD) {
|
|
1522
|
+
if (signal.action === "hold") {
|
|
1523
|
+
return true;
|
|
1524
|
+
}
|
|
1525
|
+
if (signal.reduceOnly || signal.action === "close_long" || signal.action === "close_short") {
|
|
1526
|
+
return true;
|
|
1527
|
+
}
|
|
1528
|
+
if (signal.confidence < 0.5) {
|
|
1529
|
+
console.warn(`Perp signal confidence too low: ${signal.confidence} for ${signal.instrument}`);
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
if (signal.leverage > maxLeverage) {
|
|
1533
|
+
console.warn(`Perp signal leverage ${signal.leverage}x exceeds max ${maxLeverage}x for ${signal.instrument}`);
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
const signalNotional = signal.size * signal.price;
|
|
1537
|
+
if (signalNotional > maxNotionalUSD) {
|
|
1538
|
+
console.warn(`Perp signal notional $${signalNotional.toFixed(0)} exceeds max $${maxNotionalUSD} for ${signal.instrument}`);
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
const currentNotional = account.totalNotional;
|
|
1542
|
+
const projectedNotional = currentNotional + signalNotional;
|
|
1543
|
+
const projectedLeverage = account.totalEquity > 0 ? projectedNotional / account.totalEquity : 0;
|
|
1544
|
+
if (projectedLeverage > maxLeverage) {
|
|
1545
|
+
console.warn(
|
|
1546
|
+
`Perp signal would push aggregate leverage to ${projectedLeverage.toFixed(1)}x (max: ${maxLeverage}x) \u2014 blocked`
|
|
1547
|
+
);
|
|
1548
|
+
return false;
|
|
1549
|
+
}
|
|
1550
|
+
const existingPos = positions.find((p) => p.instrument === signal.instrument);
|
|
1551
|
+
if (existingPos) {
|
|
1552
|
+
const liqProximity = this.calculateLiquidationProximity(existingPos);
|
|
1553
|
+
if (liqProximity > 0.7) {
|
|
1554
|
+
console.warn(
|
|
1555
|
+
`Position ${signal.instrument} liquidation proximity ${(liqProximity * 100).toFixed(0)}% \u2014 blocking new entry`
|
|
1556
|
+
);
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const requiredMargin = signalNotional / signal.leverage;
|
|
1561
|
+
if (requiredMargin > account.availableMargin) {
|
|
1562
|
+
console.warn(
|
|
1563
|
+
`Insufficient margin for ${signal.instrument}: need $${requiredMargin.toFixed(0)}, have $${account.availableMargin.toFixed(0)}`
|
|
1564
|
+
);
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
return true;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Calculate liquidation proximity for a position (0.0 = safe, 1.0 = liquidated).
|
|
1571
|
+
*/
|
|
1572
|
+
calculateLiquidationProximity(pos) {
|
|
1573
|
+
if (pos.liquidationPrice <= 0 || pos.markPrice <= 0) return 0;
|
|
1574
|
+
if (pos.size > 0) {
|
|
1575
|
+
if (pos.markPrice <= pos.liquidationPrice) return 1;
|
|
1576
|
+
const distance = pos.markPrice - pos.liquidationPrice;
|
|
1577
|
+
const total = pos.entryPrice - pos.liquidationPrice;
|
|
1578
|
+
return total > 0 ? 1 - distance / total : 0;
|
|
1579
|
+
} else {
|
|
1580
|
+
if (pos.markPrice >= pos.liquidationPrice) return 1;
|
|
1581
|
+
const distance = pos.liquidationPrice - pos.markPrice;
|
|
1582
|
+
const total = pos.liquidationPrice - pos.entryPrice;
|
|
1583
|
+
return total > 0 ? 1 - distance / total : 0;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1468
1586
|
};
|
|
1469
1587
|
|
|
1470
1588
|
// src/vault/manager.ts
|
|
@@ -2032,139 +2150,1494 @@ function openBrowser(url) {
|
|
|
2032
2150
|
}
|
|
2033
2151
|
}
|
|
2034
2152
|
|
|
2035
|
-
// src/
|
|
2036
|
-
var
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
client;
|
|
2041
|
-
llm;
|
|
2042
|
-
strategy;
|
|
2043
|
-
executor;
|
|
2044
|
-
riskManager;
|
|
2045
|
-
marketData;
|
|
2046
|
-
vaultManager;
|
|
2047
|
-
relay = null;
|
|
2048
|
-
isRunning = false;
|
|
2049
|
-
mode = "idle";
|
|
2050
|
-
configHash;
|
|
2051
|
-
cycleCount = 0;
|
|
2052
|
-
lastCycleAt = 0;
|
|
2053
|
-
lastPortfolioValue = 0;
|
|
2054
|
-
lastEthBalance = "0";
|
|
2055
|
-
processAlive = true;
|
|
2056
|
-
riskUniverse = 0;
|
|
2057
|
-
allowedTokens = /* @__PURE__ */ new Set();
|
|
2153
|
+
// src/perp/client.ts
|
|
2154
|
+
var HyperliquidClient = class {
|
|
2155
|
+
apiUrl;
|
|
2156
|
+
meta = null;
|
|
2157
|
+
assetIndexCache = /* @__PURE__ */ new Map();
|
|
2058
2158
|
constructor(config) {
|
|
2059
|
-
this.
|
|
2159
|
+
this.apiUrl = config.apiUrl;
|
|
2160
|
+
}
|
|
2161
|
+
// ============================================================
|
|
2162
|
+
// INFO API (read-only)
|
|
2163
|
+
// ============================================================
|
|
2164
|
+
/** Fetch perpetuals metadata (asset specs, names, indices) */
|
|
2165
|
+
async getMeta() {
|
|
2166
|
+
if (this.meta) return this.meta;
|
|
2167
|
+
const resp = await this.infoRequest({ type: "meta" });
|
|
2168
|
+
this.meta = resp.universe;
|
|
2169
|
+
this.meta.forEach((asset, idx) => {
|
|
2170
|
+
this.assetIndexCache.set(asset.name, idx);
|
|
2171
|
+
});
|
|
2172
|
+
return this.meta;
|
|
2173
|
+
}
|
|
2174
|
+
/** Get asset index from symbol (e.g. "ETH" -> 1). Caches after first getMeta() call. */
|
|
2175
|
+
async getAssetIndex(coin) {
|
|
2176
|
+
if (this.assetIndexCache.has(coin)) return this.assetIndexCache.get(coin);
|
|
2177
|
+
await this.getMeta();
|
|
2178
|
+
const idx = this.assetIndexCache.get(coin);
|
|
2179
|
+
if (idx === void 0) throw new Error(`Unknown instrument: ${coin}`);
|
|
2180
|
+
return idx;
|
|
2181
|
+
}
|
|
2182
|
+
/** Get mid-market prices for all perpetuals */
|
|
2183
|
+
async getAllMids() {
|
|
2184
|
+
return this.infoRequest({ type: "allMids" });
|
|
2185
|
+
}
|
|
2186
|
+
/** Get clearinghouse state (positions, margin, equity) for a user */
|
|
2187
|
+
async getClearinghouseState(user) {
|
|
2188
|
+
return this.infoRequest({ type: "clearinghouseState", user });
|
|
2189
|
+
}
|
|
2190
|
+
/** Get user's recent fills */
|
|
2191
|
+
async getUserFills(user, startTime) {
|
|
2192
|
+
return this.infoRequest({
|
|
2193
|
+
type: "userFills",
|
|
2194
|
+
user,
|
|
2195
|
+
...startTime !== void 0 && { startTime }
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
/** Get user's fills in a time range */
|
|
2199
|
+
async getUserFillsByTime(user, startTime, endTime) {
|
|
2200
|
+
return this.infoRequest({
|
|
2201
|
+
type: "userFillsByTime",
|
|
2202
|
+
user,
|
|
2203
|
+
startTime,
|
|
2204
|
+
...endTime !== void 0 && { endTime }
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
/** Get user's open orders */
|
|
2208
|
+
async getOpenOrders(user) {
|
|
2209
|
+
return this.infoRequest({ type: "openOrders", user });
|
|
2210
|
+
}
|
|
2211
|
+
/** Get user's funding history */
|
|
2212
|
+
async getUserFunding(user, startTime) {
|
|
2213
|
+
return this.infoRequest({ type: "userFunding", user, startTime });
|
|
2214
|
+
}
|
|
2215
|
+
/** Check max approved builder fee for a user */
|
|
2216
|
+
async getMaxBuilderFee(user, builder) {
|
|
2217
|
+
const resp = await this.infoRequest({ type: "maxBuilderFee", user, builder });
|
|
2218
|
+
return resp;
|
|
2219
|
+
}
|
|
2220
|
+
/** Get L2 order book for a coin */
|
|
2221
|
+
async getL2Book(coin, depth) {
|
|
2222
|
+
return this.infoRequest({
|
|
2223
|
+
type: "l2Book",
|
|
2224
|
+
coin,
|
|
2225
|
+
...depth !== void 0 && { nSigFigs: depth }
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
// ============================================================
|
|
2229
|
+
// HIGH-LEVEL HELPERS
|
|
2230
|
+
// ============================================================
|
|
2231
|
+
/** Get parsed positions for a user address */
|
|
2232
|
+
async getPositions(user) {
|
|
2233
|
+
const state = await this.getClearinghouseState(user);
|
|
2234
|
+
return state.assetPositions.filter((p) => parseFloat(p.position.szi) !== 0).map((p) => this.parsePosition(p));
|
|
2235
|
+
}
|
|
2236
|
+
/** Get account summary (equity, margin, leverage) */
|
|
2237
|
+
async getAccountSummary(user) {
|
|
2238
|
+
const state = await this.getClearinghouseState(user);
|
|
2239
|
+
const crossMarginSummary = state.crossMarginSummary;
|
|
2240
|
+
const totalEquity = parseFloat(crossMarginSummary.accountValue);
|
|
2241
|
+
const totalNotional = parseFloat(crossMarginSummary.totalNtlPos);
|
|
2242
|
+
const totalMarginUsed = parseFloat(crossMarginSummary.totalMarginUsed);
|
|
2243
|
+
return {
|
|
2244
|
+
totalEquity,
|
|
2245
|
+
availableMargin: totalEquity - totalMarginUsed,
|
|
2246
|
+
totalMarginUsed,
|
|
2247
|
+
totalUnrealizedPnl: parseFloat(crossMarginSummary.totalRawUsd) - totalEquity,
|
|
2248
|
+
totalNotional,
|
|
2249
|
+
maintenanceMargin: totalMarginUsed * 0.5,
|
|
2250
|
+
// Approximate
|
|
2251
|
+
effectiveLeverage: totalEquity > 0 ? totalNotional / totalEquity : 0,
|
|
2252
|
+
cashBalance: parseFloat(state.crossMarginSummary.accountValue) - state.assetPositions.reduce(
|
|
2253
|
+
(sum, p) => sum + parseFloat(p.position.unrealizedPnl),
|
|
2254
|
+
0
|
|
2255
|
+
)
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
/** Get market data for a list of instruments */
|
|
2259
|
+
async getMarketData(instruments) {
|
|
2260
|
+
const mids = await this.getAllMids();
|
|
2261
|
+
const meta = await this.getMeta();
|
|
2262
|
+
return instruments.filter((inst) => mids[inst] !== void 0).map((inst) => {
|
|
2263
|
+
const midPrice = parseFloat(mids[inst]);
|
|
2264
|
+
const assetMeta = meta.find((m) => m.name === inst);
|
|
2265
|
+
return {
|
|
2266
|
+
instrument: inst,
|
|
2267
|
+
midPrice,
|
|
2268
|
+
bestBid: midPrice,
|
|
2269
|
+
// Approximate — use L2 book for exact
|
|
2270
|
+
bestAsk: midPrice,
|
|
2271
|
+
funding8h: 0,
|
|
2272
|
+
// Would need separate funding API call
|
|
2273
|
+
openInterest: 0,
|
|
2274
|
+
// Would need separate meta call
|
|
2275
|
+
volume24h: 0,
|
|
2276
|
+
priceChange24h: 0
|
|
2277
|
+
};
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
/** Convert fills to our PerpFill type */
|
|
2281
|
+
parseFill(fill) {
|
|
2282
|
+
return {
|
|
2283
|
+
oid: fill.oid,
|
|
2284
|
+
coin: fill.coin,
|
|
2285
|
+
side: fill.side,
|
|
2286
|
+
px: fill.px,
|
|
2287
|
+
sz: fill.sz,
|
|
2288
|
+
fee: fill.fee,
|
|
2289
|
+
time: fill.time,
|
|
2290
|
+
hash: fill.hash,
|
|
2291
|
+
isMaker: fill.startPosition !== fill.px,
|
|
2292
|
+
// Approximate maker detection
|
|
2293
|
+
builderFee: fill.builderFee,
|
|
2294
|
+
liquidation: fill.liquidation
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
// ============================================================
|
|
2298
|
+
// PRIVATE HELPERS
|
|
2299
|
+
// ============================================================
|
|
2300
|
+
parsePosition(ap) {
|
|
2301
|
+
const pos = ap.position;
|
|
2302
|
+
const size = parseFloat(pos.szi);
|
|
2303
|
+
const entryPrice = parseFloat(pos.entryPx || "0");
|
|
2304
|
+
const markPrice = parseFloat(pos.positionValue || "0") / Math.abs(size || 1);
|
|
2305
|
+
return {
|
|
2306
|
+
instrument: pos.coin,
|
|
2307
|
+
assetIndex: this.assetIndexCache.get(pos.coin) ?? -1,
|
|
2308
|
+
size,
|
|
2309
|
+
entryPrice,
|
|
2310
|
+
markPrice,
|
|
2311
|
+
unrealizedPnl: parseFloat(pos.unrealizedPnl),
|
|
2312
|
+
leverage: parseFloat(pos.leverage?.value || "1"),
|
|
2313
|
+
marginType: pos.leverage?.type === "isolated" ? "isolated" : "cross",
|
|
2314
|
+
liquidationPrice: parseFloat(pos.liquidationPx || "0"),
|
|
2315
|
+
notionalUSD: Math.abs(size) * markPrice,
|
|
2316
|
+
marginUsed: parseFloat(pos.marginUsed)
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
async infoRequest(body) {
|
|
2320
|
+
const resp = await fetch(`${this.apiUrl}/info`, {
|
|
2321
|
+
method: "POST",
|
|
2322
|
+
headers: { "Content-Type": "application/json" },
|
|
2323
|
+
body: JSON.stringify(body)
|
|
2324
|
+
});
|
|
2325
|
+
if (!resp.ok) {
|
|
2326
|
+
throw new Error(`Hyperliquid Info API error: ${resp.status} ${await resp.text()}`);
|
|
2327
|
+
}
|
|
2328
|
+
return resp.json();
|
|
2329
|
+
}
|
|
2330
|
+
};
|
|
2331
|
+
|
|
2332
|
+
// src/perp/signer.ts
|
|
2333
|
+
var import_viem3 = require("viem");
|
|
2334
|
+
var HYPERLIQUID_DOMAIN = {
|
|
2335
|
+
name: "HyperliquidSignTransaction",
|
|
2336
|
+
version: "1",
|
|
2337
|
+
chainId: 42161n,
|
|
2338
|
+
// Always Arbitrum chain ID
|
|
2339
|
+
verifyingContract: "0x0000000000000000000000000000000000000000"
|
|
2340
|
+
};
|
|
2341
|
+
var HYPERLIQUID_TYPES = {
|
|
2342
|
+
HyperliquidTransaction: [
|
|
2343
|
+
{ name: "hyperliquidChain", type: "string" },
|
|
2344
|
+
{ name: "action", type: "string" },
|
|
2345
|
+
{ name: "nonce", type: "uint64" }
|
|
2346
|
+
]
|
|
2347
|
+
};
|
|
2348
|
+
var lastNonce = 0n;
|
|
2349
|
+
function getNextNonce() {
|
|
2350
|
+
const now = BigInt(Date.now());
|
|
2351
|
+
if (now <= lastNonce) {
|
|
2352
|
+
lastNonce = lastNonce + 1n;
|
|
2353
|
+
} else {
|
|
2354
|
+
lastNonce = now;
|
|
2355
|
+
}
|
|
2356
|
+
return lastNonce;
|
|
2357
|
+
}
|
|
2358
|
+
var HyperliquidSigner = class {
|
|
2359
|
+
constructor(walletClient) {
|
|
2360
|
+
this.walletClient = walletClient;
|
|
2060
2361
|
}
|
|
2061
2362
|
/**
|
|
2062
|
-
*
|
|
2363
|
+
* Sign an exchange action (order, cancel, etc.)
|
|
2364
|
+
*
|
|
2365
|
+
* @param action - The action object (will be JSON-serialized)
|
|
2366
|
+
* @param nonce - Nonce (defaults to current timestamp)
|
|
2367
|
+
* @returns Signature hex string
|
|
2063
2368
|
*/
|
|
2064
|
-
async
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2369
|
+
async signAction(action, nonce) {
|
|
2370
|
+
const actionNonce = nonce ?? getNextNonce();
|
|
2371
|
+
const actionStr = JSON.stringify(action);
|
|
2372
|
+
const account = this.walletClient.account;
|
|
2373
|
+
if (!account) throw new Error("Wallet client has no account");
|
|
2374
|
+
const signature = await this.walletClient.signTypedData({
|
|
2375
|
+
account,
|
|
2376
|
+
domain: HYPERLIQUID_DOMAIN,
|
|
2377
|
+
types: HYPERLIQUID_TYPES,
|
|
2378
|
+
primaryType: "HyperliquidTransaction",
|
|
2379
|
+
message: {
|
|
2380
|
+
hyperliquidChain: "Mainnet",
|
|
2381
|
+
action: actionStr,
|
|
2382
|
+
nonce: actionNonce
|
|
2383
|
+
}
|
|
2069
2384
|
});
|
|
2070
|
-
|
|
2071
|
-
const agent = await this.client.registry.getAgent(BigInt(this.config.agentId));
|
|
2072
|
-
if (!agent) {
|
|
2073
|
-
throw new Error(`Agent ID ${this.config.agentId} not found on-chain. Please register first.`);
|
|
2074
|
-
}
|
|
2075
|
-
console.log(`Agent verified: ${agent.name}`);
|
|
2076
|
-
await this.ensureWalletLinked();
|
|
2077
|
-
await this.loadTradingRestrictions();
|
|
2078
|
-
console.log(`Initializing LLM: ${this.config.llm.provider}`);
|
|
2079
|
-
this.llm = await createLLMAdapter(this.config.llm);
|
|
2080
|
-
const llmMeta = this.llm.getMetadata();
|
|
2081
|
-
console.log(`LLM ready: ${llmMeta.provider} (${llmMeta.model})`);
|
|
2082
|
-
await this.syncConfigHash();
|
|
2083
|
-
this.strategy = await loadStrategy();
|
|
2084
|
-
this.executor = new TradeExecutor(this.client, this.config, () => this.getConfigHash());
|
|
2085
|
-
this.riskManager = new RiskManager(this.config.trading);
|
|
2086
|
-
this.marketData = new MarketDataService(this.getRpcUrl());
|
|
2087
|
-
await this.initializeVaultManager();
|
|
2088
|
-
await this.initializeRelay();
|
|
2089
|
-
console.log("Agent initialized successfully");
|
|
2385
|
+
return { signature, nonce: actionNonce };
|
|
2090
2386
|
}
|
|
2091
2387
|
/**
|
|
2092
|
-
*
|
|
2388
|
+
* Sign a user-level approval action (approve builder fee, approve agent).
|
|
2389
|
+
* These use the same EIP-712 structure but with different action payloads.
|
|
2093
2390
|
*/
|
|
2094
|
-
async
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2391
|
+
async signApproval(action, nonce) {
|
|
2392
|
+
return this.signAction(action, nonce);
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Get the signer's address
|
|
2396
|
+
*/
|
|
2397
|
+
getAddress() {
|
|
2398
|
+
const account = this.walletClient.account;
|
|
2399
|
+
if (!account) throw new Error("Wallet client has no account");
|
|
2400
|
+
return account.address;
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
function fillHashToBytes32(fillHash) {
|
|
2404
|
+
if (fillHash.startsWith("0x") && fillHash.length === 66) {
|
|
2405
|
+
return fillHash;
|
|
2406
|
+
}
|
|
2407
|
+
return (0, import_viem3.keccak256)((0, import_viem3.encodePacked)(["string"], [fillHash]));
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// src/perp/orders.ts
|
|
2411
|
+
var OrderManager = class {
|
|
2412
|
+
client;
|
|
2413
|
+
signer;
|
|
2414
|
+
config;
|
|
2415
|
+
constructor(client, signer, config) {
|
|
2416
|
+
this.client = client;
|
|
2417
|
+
this.signer = signer;
|
|
2418
|
+
this.config = config;
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* Place an order on Hyperliquid from a trade signal.
|
|
2422
|
+
* Attaches builder fee for revenue collection.
|
|
2423
|
+
*/
|
|
2424
|
+
async placeOrder(signal) {
|
|
2425
|
+
try {
|
|
2426
|
+
const assetIndex = await this.client.getAssetIndex(signal.instrument);
|
|
2427
|
+
const isBuy = signal.action === "open_long" || signal.action === "close_short";
|
|
2428
|
+
const side = isBuy ? "B" : "A";
|
|
2429
|
+
const orderWire = {
|
|
2430
|
+
a: assetIndex,
|
|
2431
|
+
b: isBuy,
|
|
2432
|
+
p: signal.orderType === "market" ? this.getMarketPrice(signal) : signal.price.toString(),
|
|
2433
|
+
s: signal.size.toString(),
|
|
2434
|
+
r: signal.reduceOnly,
|
|
2435
|
+
t: signal.orderType === "market" ? { limit: { tif: "Ioc" } } : { limit: { tif: "Gtc" } }
|
|
2436
|
+
};
|
|
2437
|
+
const action = {
|
|
2438
|
+
type: "order",
|
|
2439
|
+
orders: [orderWire],
|
|
2440
|
+
grouping: "na",
|
|
2441
|
+
builder: {
|
|
2442
|
+
b: this.config.builderAddress,
|
|
2443
|
+
f: this.config.builderFeeTenthsBps
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
const nonce = getNextNonce();
|
|
2447
|
+
const { signature } = await this.signer.signAction(action, nonce);
|
|
2448
|
+
const address = this.signer.getAddress();
|
|
2449
|
+
const resp = await this.exchangeRequest({
|
|
2450
|
+
action,
|
|
2451
|
+
nonce: Number(nonce),
|
|
2452
|
+
signature: { r: signature.slice(0, 66), s: `0x${signature.slice(66, 130)}`, v: parseInt(signature.slice(130, 132), 16) },
|
|
2453
|
+
vaultAddress: null
|
|
2454
|
+
});
|
|
2455
|
+
return this.parseOrderResponse(resp);
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2458
|
+
console.error(`Order placement failed for ${signal.instrument}:`, message);
|
|
2459
|
+
return {
|
|
2460
|
+
success: false,
|
|
2461
|
+
status: "error",
|
|
2462
|
+
error: message
|
|
2463
|
+
};
|
|
2100
2464
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2465
|
+
}
|
|
2466
|
+
/**
|
|
2467
|
+
* Cancel an open order by ID.
|
|
2468
|
+
*/
|
|
2469
|
+
async cancelOrder(instrument, orderId) {
|
|
2470
|
+
try {
|
|
2471
|
+
const assetIndex = await this.client.getAssetIndex(instrument);
|
|
2472
|
+
const action = {
|
|
2473
|
+
type: "cancel",
|
|
2474
|
+
cancels: [{ a: assetIndex, o: orderId }]
|
|
2475
|
+
};
|
|
2476
|
+
const nonce = getNextNonce();
|
|
2477
|
+
const { signature } = await this.signer.signAction(action, nonce);
|
|
2478
|
+
await this.exchangeRequest({
|
|
2479
|
+
action,
|
|
2480
|
+
nonce: Number(nonce),
|
|
2481
|
+
signature: { r: signature.slice(0, 66), s: `0x${signature.slice(66, 130)}`, v: parseInt(signature.slice(130, 132), 16) },
|
|
2482
|
+
vaultAddress: null
|
|
2483
|
+
});
|
|
2484
|
+
console.log(`Cancelled order ${orderId} for ${instrument}`);
|
|
2485
|
+
return true;
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2488
|
+
console.error(`Cancel failed for order ${orderId}:`, message);
|
|
2489
|
+
return false;
|
|
2105
2490
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Close an entire position for an instrument.
|
|
2494
|
+
* Uses a market order with reduceOnly flag.
|
|
2495
|
+
*/
|
|
2496
|
+
async closePosition(instrument, positionSize) {
|
|
2497
|
+
const isLong = positionSize > 0;
|
|
2498
|
+
const signal = {
|
|
2499
|
+
action: isLong ? "close_long" : "close_short",
|
|
2500
|
+
instrument,
|
|
2501
|
+
size: Math.abs(positionSize),
|
|
2502
|
+
price: 0,
|
|
2503
|
+
leverage: 1,
|
|
2504
|
+
orderType: "market",
|
|
2505
|
+
reduceOnly: true,
|
|
2506
|
+
confidence: 1,
|
|
2507
|
+
reasoning: "Position close"
|
|
2508
|
+
};
|
|
2509
|
+
return this.placeOrder(signal);
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* Update leverage for an instrument.
|
|
2513
|
+
*/
|
|
2514
|
+
async updateLeverage(instrument, leverage, isCross = true) {
|
|
2115
2515
|
try {
|
|
2116
|
-
await this.
|
|
2117
|
-
|
|
2118
|
-
|
|
2516
|
+
const assetIndex = await this.client.getAssetIndex(instrument);
|
|
2517
|
+
const action = {
|
|
2518
|
+
type: "updateLeverage",
|
|
2519
|
+
asset: assetIndex,
|
|
2520
|
+
isCross,
|
|
2521
|
+
leverage
|
|
2522
|
+
};
|
|
2523
|
+
const nonce = getNextNonce();
|
|
2524
|
+
const { signature } = await this.signer.signAction(action, nonce);
|
|
2525
|
+
await this.exchangeRequest({
|
|
2526
|
+
action,
|
|
2527
|
+
nonce: Number(nonce),
|
|
2528
|
+
signature: { r: signature.slice(0, 66), s: `0x${signature.slice(66, 130)}`, v: parseInt(signature.slice(130, 132), 16) },
|
|
2529
|
+
vaultAddress: null
|
|
2530
|
+
});
|
|
2531
|
+
console.log(`Leverage updated for ${instrument}: ${leverage}x (${isCross ? "cross" : "isolated"})`);
|
|
2532
|
+
return true;
|
|
2119
2533
|
} catch (error) {
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
);
|
|
2534
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2535
|
+
console.error(`Leverage update failed for ${instrument}:`, message);
|
|
2536
|
+
return false;
|
|
2124
2537
|
}
|
|
2125
2538
|
}
|
|
2539
|
+
// ============================================================
|
|
2540
|
+
// PRIVATE HELPERS
|
|
2541
|
+
// ============================================================
|
|
2126
2542
|
/**
|
|
2127
|
-
*
|
|
2543
|
+
* Get a market price string for IOC orders.
|
|
2544
|
+
* Uses a generous slippage buffer to ensure fills.
|
|
2128
2545
|
*/
|
|
2129
|
-
|
|
2130
|
-
const
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2546
|
+
getMarketPrice(signal) {
|
|
2547
|
+
const isBuy = signal.action === "open_long" || signal.action === "close_short";
|
|
2548
|
+
if (signal.price > 0) {
|
|
2549
|
+
const slippage = isBuy ? 1.005 : 0.995;
|
|
2550
|
+
return (signal.price * slippage).toString();
|
|
2551
|
+
}
|
|
2552
|
+
return "0";
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Parse Hyperliquid exchange response into OrderResult.
|
|
2556
|
+
*/
|
|
2557
|
+
parseOrderResponse(resp) {
|
|
2558
|
+
if (resp?.status === "ok" && resp?.response?.type === "order") {
|
|
2559
|
+
const statuses = resp.response.data?.statuses || [];
|
|
2560
|
+
if (statuses.length > 0) {
|
|
2561
|
+
const status = statuses[0];
|
|
2562
|
+
if (status.filled) {
|
|
2563
|
+
return {
|
|
2564
|
+
success: true,
|
|
2565
|
+
orderId: status.filled.oid,
|
|
2566
|
+
status: "filled",
|
|
2567
|
+
avgPrice: status.filled.avgPx,
|
|
2568
|
+
filledSize: status.filled.totalSz
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
if (status.resting) {
|
|
2572
|
+
return {
|
|
2573
|
+
success: true,
|
|
2574
|
+
orderId: status.resting.oid,
|
|
2575
|
+
status: "resting"
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
if (status.error) {
|
|
2579
|
+
return {
|
|
2580
|
+
success: false,
|
|
2581
|
+
status: "error",
|
|
2582
|
+
error: status.error
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2147
2585
|
}
|
|
2148
2586
|
}
|
|
2587
|
+
return {
|
|
2588
|
+
success: false,
|
|
2589
|
+
status: "error",
|
|
2590
|
+
error: `Unexpected response: ${JSON.stringify(resp)}`
|
|
2591
|
+
};
|
|
2149
2592
|
}
|
|
2150
2593
|
/**
|
|
2151
|
-
*
|
|
2152
|
-
* If the trading wallet differs from the owner, enters a recovery loop
|
|
2153
|
-
* that waits for the owner to link it from the website.
|
|
2594
|
+
* Send a signed request to the Hyperliquid Exchange API.
|
|
2154
2595
|
*/
|
|
2155
|
-
async
|
|
2156
|
-
const
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2596
|
+
async exchangeRequest(body) {
|
|
2597
|
+
const resp = await fetch(`${this.config.apiUrl}/exchange`, {
|
|
2598
|
+
method: "POST",
|
|
2599
|
+
headers: { "Content-Type": "application/json" },
|
|
2600
|
+
body: JSON.stringify(body)
|
|
2601
|
+
});
|
|
2602
|
+
if (!resp.ok) {
|
|
2603
|
+
throw new Error(`Hyperliquid Exchange API error: ${resp.status} ${await resp.text()}`);
|
|
2604
|
+
}
|
|
2605
|
+
return resp.json();
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
// src/perp/positions.ts
|
|
2610
|
+
var PositionManager = class {
|
|
2611
|
+
client;
|
|
2612
|
+
userAddress;
|
|
2613
|
+
/** Cached positions (updated each cycle) */
|
|
2614
|
+
cachedPositions = [];
|
|
2615
|
+
cachedAccount = null;
|
|
2616
|
+
lastRefreshAt = 0;
|
|
2617
|
+
/** Cache TTL in ms (5 seconds — positions refresh each cycle anyway) */
|
|
2618
|
+
cacheTtlMs = 5e3;
|
|
2619
|
+
constructor(client, userAddress) {
|
|
2620
|
+
this.client = client;
|
|
2621
|
+
this.userAddress = userAddress;
|
|
2622
|
+
}
|
|
2623
|
+
// ============================================================
|
|
2624
|
+
// POSITION QUERIES
|
|
2625
|
+
// ============================================================
|
|
2626
|
+
/**
|
|
2627
|
+
* Get all open positions. Uses cache if fresh.
|
|
2628
|
+
*/
|
|
2629
|
+
async getPositions(forceRefresh = false) {
|
|
2630
|
+
if (!forceRefresh && this.isCacheFresh()) {
|
|
2631
|
+
return this.cachedPositions;
|
|
2632
|
+
}
|
|
2633
|
+
await this.refresh();
|
|
2634
|
+
return this.cachedPositions;
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Get a specific position by instrument.
|
|
2638
|
+
* Returns null if no position is open.
|
|
2639
|
+
*/
|
|
2640
|
+
async getPosition(instrument) {
|
|
2641
|
+
const positions = await this.getPositions();
|
|
2642
|
+
return positions.find((p) => p.instrument === instrument) ?? null;
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* Get account summary (equity, margin, leverage).
|
|
2646
|
+
*/
|
|
2647
|
+
async getAccountSummary(forceRefresh = false) {
|
|
2648
|
+
if (!forceRefresh && this.isCacheFresh() && this.cachedAccount) {
|
|
2649
|
+
return this.cachedAccount;
|
|
2650
|
+
}
|
|
2651
|
+
await this.refresh();
|
|
2652
|
+
return this.cachedAccount;
|
|
2653
|
+
}
|
|
2654
|
+
// ============================================================
|
|
2655
|
+
// LIQUIDATION MONITORING
|
|
2656
|
+
// ============================================================
|
|
2657
|
+
/**
|
|
2658
|
+
* Get liquidation proximity for all positions.
|
|
2659
|
+
* Returns a value between 0.0 (safe) and 1.0 (at liquidation price).
|
|
2660
|
+
* Values above 0.7 should trigger risk warnings.
|
|
2661
|
+
*/
|
|
2662
|
+
async getLiquidationProximity() {
|
|
2663
|
+
const positions = await this.getPositions();
|
|
2664
|
+
const proximities = /* @__PURE__ */ new Map();
|
|
2665
|
+
for (const pos of positions) {
|
|
2666
|
+
if (pos.liquidationPrice <= 0 || pos.markPrice <= 0) {
|
|
2667
|
+
proximities.set(pos.instrument, 0);
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
let proximity;
|
|
2671
|
+
if (pos.size > 0) {
|
|
2672
|
+
if (pos.markPrice <= pos.liquidationPrice) {
|
|
2673
|
+
proximity = 1;
|
|
2674
|
+
} else {
|
|
2675
|
+
const distanceToLiq = pos.markPrice - pos.liquidationPrice;
|
|
2676
|
+
const entryToLiq = pos.entryPrice - pos.liquidationPrice;
|
|
2677
|
+
proximity = entryToLiq > 0 ? 1 - distanceToLiq / entryToLiq : 0;
|
|
2678
|
+
}
|
|
2679
|
+
} else {
|
|
2680
|
+
if (pos.markPrice >= pos.liquidationPrice) {
|
|
2681
|
+
proximity = 1;
|
|
2682
|
+
} else {
|
|
2683
|
+
const distanceToLiq = pos.liquidationPrice - pos.markPrice;
|
|
2684
|
+
const entryToLiq = pos.liquidationPrice - pos.entryPrice;
|
|
2685
|
+
proximity = entryToLiq > 0 ? 1 - distanceToLiq / entryToLiq : 0;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
proximities.set(pos.instrument, Math.max(0, Math.min(1, proximity)));
|
|
2689
|
+
}
|
|
2690
|
+
return proximities;
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Check if any position is dangerously close to liquidation.
|
|
2694
|
+
* Returns instruments with proximity > threshold.
|
|
2695
|
+
*/
|
|
2696
|
+
async getDangerousPositions(threshold = 0.7) {
|
|
2697
|
+
const positions = await this.getPositions();
|
|
2698
|
+
const proximities = await this.getLiquidationProximity();
|
|
2699
|
+
return positions.filter((p) => {
|
|
2700
|
+
const prox = proximities.get(p.instrument) ?? 0;
|
|
2701
|
+
return prox > threshold;
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
// ============================================================
|
|
2705
|
+
// SUMMARY HELPERS
|
|
2706
|
+
// ============================================================
|
|
2707
|
+
/**
|
|
2708
|
+
* Get total unrealized PnL across all positions.
|
|
2709
|
+
*/
|
|
2710
|
+
async getTotalUnrealizedPnl() {
|
|
2711
|
+
const positions = await this.getPositions();
|
|
2712
|
+
return positions.reduce((sum, p) => sum + p.unrealizedPnl, 0);
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Get total notional exposure.
|
|
2716
|
+
*/
|
|
2717
|
+
async getTotalNotional() {
|
|
2718
|
+
const positions = await this.getPositions();
|
|
2719
|
+
return positions.reduce((sum, p) => sum + p.notionalUSD, 0);
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Get position count.
|
|
2723
|
+
*/
|
|
2724
|
+
async getPositionCount() {
|
|
2725
|
+
const positions = await this.getPositions();
|
|
2726
|
+
return positions.length;
|
|
2727
|
+
}
|
|
2728
|
+
// ============================================================
|
|
2729
|
+
// CACHE MANAGEMENT
|
|
2730
|
+
// ============================================================
|
|
2731
|
+
/**
|
|
2732
|
+
* Force refresh positions and account from Hyperliquid.
|
|
2733
|
+
*/
|
|
2734
|
+
async refresh() {
|
|
2735
|
+
try {
|
|
2736
|
+
const [positions, account] = await Promise.all([
|
|
2737
|
+
this.client.getPositions(this.userAddress),
|
|
2738
|
+
this.client.getAccountSummary(this.userAddress)
|
|
2739
|
+
]);
|
|
2740
|
+
this.cachedPositions = positions;
|
|
2741
|
+
this.cachedAccount = account;
|
|
2742
|
+
this.lastRefreshAt = Date.now();
|
|
2743
|
+
} catch (error) {
|
|
2744
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2745
|
+
console.error("Failed to refresh positions:", message);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Check if cache is still fresh.
|
|
2750
|
+
*/
|
|
2751
|
+
isCacheFresh() {
|
|
2752
|
+
return Date.now() - this.lastRefreshAt < this.cacheTtlMs;
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
|
|
2756
|
+
// src/perp/websocket.ts
|
|
2757
|
+
var import_ws2 = __toESM(require("ws"));
|
|
2758
|
+
var HyperliquidWebSocket = class {
|
|
2759
|
+
wsUrl;
|
|
2760
|
+
userAddress;
|
|
2761
|
+
client;
|
|
2762
|
+
ws = null;
|
|
2763
|
+
reconnectAttempts = 0;
|
|
2764
|
+
maxReconnectAttempts = 20;
|
|
2765
|
+
baseReconnectMs = 1e3;
|
|
2766
|
+
maxReconnectMs = 6e4;
|
|
2767
|
+
reconnectTimer = null;
|
|
2768
|
+
pingTimer = null;
|
|
2769
|
+
isConnecting = false;
|
|
2770
|
+
shouldReconnect = true;
|
|
2771
|
+
/** Last processed fill time (ms) — used for REST backfill on reconnect */
|
|
2772
|
+
lastProcessedFillTime = 0;
|
|
2773
|
+
/** Callbacks */
|
|
2774
|
+
onFill = null;
|
|
2775
|
+
onFunding = null;
|
|
2776
|
+
onLiquidation = null;
|
|
2777
|
+
constructor(config, userAddress, client) {
|
|
2778
|
+
this.wsUrl = config.wsUrl;
|
|
2779
|
+
this.userAddress = userAddress;
|
|
2780
|
+
this.client = client;
|
|
2781
|
+
}
|
|
2782
|
+
// ============================================================
|
|
2783
|
+
// CONNECTION
|
|
2784
|
+
// ============================================================
|
|
2785
|
+
/**
|
|
2786
|
+
* Connect to Hyperliquid WebSocket and subscribe to user events.
|
|
2787
|
+
*/
|
|
2788
|
+
async connect() {
|
|
2789
|
+
if (this.ws?.readyState === import_ws2.default.OPEN || this.isConnecting) {
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
this.isConnecting = true;
|
|
2793
|
+
this.shouldReconnect = true;
|
|
2794
|
+
return new Promise((resolve, reject) => {
|
|
2795
|
+
try {
|
|
2796
|
+
this.ws = new import_ws2.default(this.wsUrl);
|
|
2797
|
+
this.ws.on("open", () => {
|
|
2798
|
+
this.isConnecting = false;
|
|
2799
|
+
this.reconnectAttempts = 0;
|
|
2800
|
+
console.log("Hyperliquid WebSocket connected");
|
|
2801
|
+
this.subscribe({
|
|
2802
|
+
type: "subscribe",
|
|
2803
|
+
subscription: { type: "userFills", user: this.userAddress }
|
|
2804
|
+
});
|
|
2805
|
+
this.subscribe({
|
|
2806
|
+
type: "subscribe",
|
|
2807
|
+
subscription: { type: "userFundings", user: this.userAddress }
|
|
2808
|
+
});
|
|
2809
|
+
this.startPing();
|
|
2810
|
+
this.backfillMissedFills().catch((err) => {
|
|
2811
|
+
console.warn("Fill backfill failed:", err instanceof Error ? err.message : err);
|
|
2812
|
+
});
|
|
2813
|
+
resolve();
|
|
2814
|
+
});
|
|
2815
|
+
this.ws.on("message", (data) => {
|
|
2816
|
+
this.handleMessage(data);
|
|
2817
|
+
});
|
|
2818
|
+
this.ws.on("close", (code, reason) => {
|
|
2819
|
+
this.isConnecting = false;
|
|
2820
|
+
console.log(`Hyperliquid WebSocket closed: ${code} ${reason.toString()}`);
|
|
2821
|
+
this.stopPing();
|
|
2822
|
+
this.scheduleReconnect();
|
|
2823
|
+
});
|
|
2824
|
+
this.ws.on("error", (error) => {
|
|
2825
|
+
this.isConnecting = false;
|
|
2826
|
+
console.error("Hyperliquid WebSocket error:", error.message);
|
|
2827
|
+
if (this.reconnectAttempts === 0) {
|
|
2828
|
+
reject(error);
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
this.isConnecting = false;
|
|
2833
|
+
reject(error);
|
|
2834
|
+
}
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Disconnect and stop reconnecting.
|
|
2839
|
+
*/
|
|
2840
|
+
disconnect() {
|
|
2841
|
+
this.shouldReconnect = false;
|
|
2842
|
+
if (this.reconnectTimer) {
|
|
2843
|
+
clearTimeout(this.reconnectTimer);
|
|
2844
|
+
this.reconnectTimer = null;
|
|
2845
|
+
}
|
|
2846
|
+
this.stopPing();
|
|
2847
|
+
if (this.ws) {
|
|
2848
|
+
this.ws.removeAllListeners();
|
|
2849
|
+
if (this.ws.readyState === import_ws2.default.OPEN) {
|
|
2850
|
+
this.ws.close(1e3, "Client disconnect");
|
|
2851
|
+
}
|
|
2852
|
+
this.ws = null;
|
|
2853
|
+
}
|
|
2854
|
+
console.log("Hyperliquid WebSocket disconnected");
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Check if WebSocket is connected.
|
|
2858
|
+
*/
|
|
2859
|
+
get isConnected() {
|
|
2860
|
+
return this.ws?.readyState === import_ws2.default.OPEN;
|
|
2861
|
+
}
|
|
2862
|
+
// ============================================================
|
|
2863
|
+
// EVENT HANDLERS
|
|
2864
|
+
// ============================================================
|
|
2865
|
+
/**
|
|
2866
|
+
* Register callback for fill events.
|
|
2867
|
+
*/
|
|
2868
|
+
onFillReceived(callback) {
|
|
2869
|
+
this.onFill = callback;
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Register callback for funding payment events.
|
|
2873
|
+
*/
|
|
2874
|
+
onFundingReceived(callback) {
|
|
2875
|
+
this.onFunding = callback;
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Register callback for liquidation events.
|
|
2879
|
+
*/
|
|
2880
|
+
onLiquidationDetected(callback) {
|
|
2881
|
+
this.onLiquidation = callback;
|
|
2882
|
+
}
|
|
2883
|
+
/**
|
|
2884
|
+
* Get the last processed fill time (for external checkpoint management).
|
|
2885
|
+
*/
|
|
2886
|
+
getLastProcessedFillTime() {
|
|
2887
|
+
return this.lastProcessedFillTime;
|
|
2888
|
+
}
|
|
2889
|
+
// ============================================================
|
|
2890
|
+
// MESSAGE HANDLING
|
|
2891
|
+
// ============================================================
|
|
2892
|
+
handleMessage(data) {
|
|
2893
|
+
try {
|
|
2894
|
+
const msg = JSON.parse(data.toString());
|
|
2895
|
+
if (msg.channel === "userFills") {
|
|
2896
|
+
this.handleFillMessage(msg.data);
|
|
2897
|
+
} else if (msg.channel === "userFundings") {
|
|
2898
|
+
this.handleFundingMessage(msg.data);
|
|
2899
|
+
}
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
handleFillMessage(fills) {
|
|
2904
|
+
if (!Array.isArray(fills) || !this.onFill) return;
|
|
2905
|
+
for (const rawFill of fills) {
|
|
2906
|
+
const fill = this.client.parseFill(rawFill);
|
|
2907
|
+
if (fill.time > this.lastProcessedFillTime) {
|
|
2908
|
+
this.lastProcessedFillTime = fill.time;
|
|
2909
|
+
}
|
|
2910
|
+
if (fill.liquidation && this.onLiquidation) {
|
|
2911
|
+
this.onLiquidation(fill.coin, parseFloat(fill.sz));
|
|
2912
|
+
}
|
|
2913
|
+
this.onFill(fill);
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
handleFundingMessage(fundings) {
|
|
2917
|
+
if (!Array.isArray(fundings) || !this.onFunding) return;
|
|
2918
|
+
for (const funding of fundings) {
|
|
2919
|
+
this.onFunding({
|
|
2920
|
+
time: funding.time,
|
|
2921
|
+
coin: funding.coin,
|
|
2922
|
+
usdc: funding.usdc,
|
|
2923
|
+
szi: funding.szi,
|
|
2924
|
+
fundingRate: funding.fundingRate
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
// ============================================================
|
|
2929
|
+
// BACKFILL
|
|
2930
|
+
// ============================================================
|
|
2931
|
+
/**
|
|
2932
|
+
* Backfill fills that may have been missed during WebSocket downtime.
|
|
2933
|
+
* Uses the last processed fill time as the starting point.
|
|
2934
|
+
*/
|
|
2935
|
+
async backfillMissedFills() {
|
|
2936
|
+
if (this.lastProcessedFillTime === 0 || !this.onFill) {
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
console.log(`Backfilling fills since ${new Date(this.lastProcessedFillTime).toISOString()}`);
|
|
2940
|
+
const fills = await this.client.getUserFillsByTime(
|
|
2941
|
+
this.userAddress,
|
|
2942
|
+
this.lastProcessedFillTime + 1
|
|
2943
|
+
// +1 to avoid duplicate
|
|
2944
|
+
);
|
|
2945
|
+
if (fills.length > 0) {
|
|
2946
|
+
console.log(`Backfilled ${fills.length} missed fills`);
|
|
2947
|
+
for (const rawFill of fills) {
|
|
2948
|
+
const fill = this.client.parseFill(rawFill);
|
|
2949
|
+
if (fill.time > this.lastProcessedFillTime) {
|
|
2950
|
+
this.lastProcessedFillTime = fill.time;
|
|
2951
|
+
}
|
|
2952
|
+
if (fill.liquidation && this.onLiquidation) {
|
|
2953
|
+
this.onLiquidation(fill.coin, parseFloat(fill.sz));
|
|
2954
|
+
}
|
|
2955
|
+
this.onFill(fill);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
// ============================================================
|
|
2960
|
+
// RECONNECTION
|
|
2961
|
+
// ============================================================
|
|
2962
|
+
scheduleReconnect() {
|
|
2963
|
+
if (!this.shouldReconnect || this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
2964
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
2965
|
+
console.error(`Hyperliquid WebSocket: max reconnect attempts (${this.maxReconnectAttempts}) reached`);
|
|
2966
|
+
}
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
const delay = Math.min(
|
|
2970
|
+
this.baseReconnectMs * Math.pow(2, this.reconnectAttempts),
|
|
2971
|
+
this.maxReconnectMs
|
|
2972
|
+
);
|
|
2973
|
+
this.reconnectAttempts++;
|
|
2974
|
+
console.log(`Hyperliquid WebSocket: reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
2975
|
+
this.reconnectTimer = setTimeout(() => {
|
|
2976
|
+
this.connect().catch((err) => {
|
|
2977
|
+
console.error("Reconnect failed:", err instanceof Error ? err.message : err);
|
|
2978
|
+
});
|
|
2979
|
+
}, delay);
|
|
2980
|
+
}
|
|
2981
|
+
// ============================================================
|
|
2982
|
+
// KEEPALIVE
|
|
2983
|
+
// ============================================================
|
|
2984
|
+
startPing() {
|
|
2985
|
+
this.stopPing();
|
|
2986
|
+
this.pingTimer = setInterval(() => {
|
|
2987
|
+
if (this.ws?.readyState === import_ws2.default.OPEN) {
|
|
2988
|
+
this.ws.send(JSON.stringify({ method: "ping" }));
|
|
2989
|
+
}
|
|
2990
|
+
}, 25e3);
|
|
2991
|
+
}
|
|
2992
|
+
stopPing() {
|
|
2993
|
+
if (this.pingTimer) {
|
|
2994
|
+
clearInterval(this.pingTimer);
|
|
2995
|
+
this.pingTimer = null;
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
// ============================================================
|
|
2999
|
+
// HELPERS
|
|
3000
|
+
// ============================================================
|
|
3001
|
+
subscribe(msg) {
|
|
3002
|
+
if (this.ws?.readyState === import_ws2.default.OPEN) {
|
|
3003
|
+
this.ws.send(JSON.stringify(msg));
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
|
|
3008
|
+
// src/perp/recorder.ts
|
|
3009
|
+
var import_viem4 = require("viem");
|
|
3010
|
+
var import_chains2 = require("viem/chains");
|
|
3011
|
+
var import_accounts3 = require("viem/accounts");
|
|
3012
|
+
var ROUTER_ADDRESS = "0x1BCFa13f677fDCf697D8b7d5120f544817F1de1A";
|
|
3013
|
+
var ROUTER_ABI = [
|
|
3014
|
+
{
|
|
3015
|
+
type: "function",
|
|
3016
|
+
name: "recordPerpTrade",
|
|
3017
|
+
stateMutability: "nonpayable",
|
|
3018
|
+
inputs: [
|
|
3019
|
+
{ name: "agentId", type: "uint256" },
|
|
3020
|
+
{ name: "configHash", type: "bytes32" },
|
|
3021
|
+
{ name: "instrument", type: "string" },
|
|
3022
|
+
{ name: "isLong", type: "bool" },
|
|
3023
|
+
{ name: "notionalUSD", type: "uint256" },
|
|
3024
|
+
{ name: "feeUSD", type: "uint256" },
|
|
3025
|
+
{ name: "fillId", type: "bytes32" }
|
|
3026
|
+
],
|
|
3027
|
+
outputs: [{ name: "", type: "bool" }]
|
|
3028
|
+
}
|
|
3029
|
+
];
|
|
3030
|
+
var MAX_RETRIES = 3;
|
|
3031
|
+
var RETRY_DELAY_MS = 5e3;
|
|
3032
|
+
var PerpTradeRecorder = class {
|
|
3033
|
+
// Use `any` for viem client types to avoid L2 chain type conflicts (Base has "deposit" tx type)
|
|
3034
|
+
publicClient;
|
|
3035
|
+
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
3036
|
+
walletClient;
|
|
3037
|
+
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
3038
|
+
account;
|
|
3039
|
+
agentId;
|
|
3040
|
+
configHash;
|
|
3041
|
+
/** Retry queue for failed recordings */
|
|
3042
|
+
retryQueue = [];
|
|
3043
|
+
/** Set of fill IDs already recorded (or in-progress) to prevent local dups */
|
|
3044
|
+
recordedFills = /* @__PURE__ */ new Set();
|
|
3045
|
+
/** Timer for processing retry queue */
|
|
3046
|
+
retryTimer = null;
|
|
3047
|
+
constructor(opts) {
|
|
3048
|
+
this.agentId = opts.agentId;
|
|
3049
|
+
this.configHash = opts.configHash;
|
|
3050
|
+
this.account = (0, import_accounts3.privateKeyToAccount)(opts.privateKey);
|
|
3051
|
+
const rpcUrl = opts.rpcUrl || "https://mainnet.base.org";
|
|
3052
|
+
const transport = (0, import_viem4.http)(rpcUrl);
|
|
3053
|
+
this.publicClient = (0, import_viem4.createPublicClient)({
|
|
3054
|
+
chain: import_chains2.base,
|
|
3055
|
+
transport
|
|
3056
|
+
});
|
|
3057
|
+
this.walletClient = (0, import_viem4.createWalletClient)({
|
|
3058
|
+
chain: import_chains2.base,
|
|
3059
|
+
transport,
|
|
3060
|
+
account: this.account
|
|
3061
|
+
});
|
|
3062
|
+
this.retryTimer = setInterval(() => this.processRetryQueue(), RETRY_DELAY_MS);
|
|
3063
|
+
}
|
|
3064
|
+
// ============================================================
|
|
3065
|
+
// PUBLIC API
|
|
3066
|
+
// ============================================================
|
|
3067
|
+
/**
|
|
3068
|
+
* Record a fill on-chain.
|
|
3069
|
+
* Converts the Hyperliquid fill into recordPerpTrade params and submits.
|
|
3070
|
+
*/
|
|
3071
|
+
async recordFill(fill) {
|
|
3072
|
+
const fillId = fillHashToBytes32(fill.hash);
|
|
3073
|
+
const fillIdStr = fillId.toLowerCase();
|
|
3074
|
+
if (this.recordedFills.has(fillIdStr)) {
|
|
3075
|
+
return { success: true };
|
|
3076
|
+
}
|
|
3077
|
+
this.recordedFills.add(fillIdStr);
|
|
3078
|
+
const params = {
|
|
3079
|
+
agentId: this.agentId,
|
|
3080
|
+
configHash: this.configHash,
|
|
3081
|
+
instrument: fill.coin,
|
|
3082
|
+
isLong: fill.side === "B",
|
|
3083
|
+
notionalUSD: this.calculateNotionalUSD(fill),
|
|
3084
|
+
feeUSD: this.calculateFeeUSD(fill),
|
|
3085
|
+
fillId
|
|
3086
|
+
};
|
|
3087
|
+
return this.submitRecord(params);
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Update the config hash (when epoch changes).
|
|
3091
|
+
*/
|
|
3092
|
+
updateConfigHash(configHash) {
|
|
3093
|
+
this.configHash = configHash;
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Get the number of fills pending retry.
|
|
3097
|
+
*/
|
|
3098
|
+
get pendingRetries() {
|
|
3099
|
+
return this.retryQueue.length;
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Get the number of fills recorded (local dedup set size).
|
|
3103
|
+
*/
|
|
3104
|
+
get recordedCount() {
|
|
3105
|
+
return this.recordedFills.size;
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Stop the recorder (clear retry timer).
|
|
3109
|
+
*/
|
|
3110
|
+
stop() {
|
|
3111
|
+
if (this.retryTimer) {
|
|
3112
|
+
clearInterval(this.retryTimer);
|
|
3113
|
+
this.retryTimer = null;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
// ============================================================
|
|
3117
|
+
// PRIVATE
|
|
3118
|
+
// ============================================================
|
|
3119
|
+
/**
|
|
3120
|
+
* Submit a recordPerpTrade transaction on Base.
|
|
3121
|
+
*/
|
|
3122
|
+
async submitRecord(params) {
|
|
3123
|
+
try {
|
|
3124
|
+
const { request } = await this.publicClient.simulateContract({
|
|
3125
|
+
address: ROUTER_ADDRESS,
|
|
3126
|
+
abi: ROUTER_ABI,
|
|
3127
|
+
functionName: "recordPerpTrade",
|
|
3128
|
+
args: [
|
|
3129
|
+
params.agentId,
|
|
3130
|
+
params.configHash,
|
|
3131
|
+
params.instrument,
|
|
3132
|
+
params.isLong,
|
|
3133
|
+
params.notionalUSD,
|
|
3134
|
+
params.feeUSD,
|
|
3135
|
+
params.fillId
|
|
3136
|
+
],
|
|
3137
|
+
account: this.account
|
|
3138
|
+
});
|
|
3139
|
+
const txHash = await this.walletClient.writeContract(request);
|
|
3140
|
+
console.log(`Perp trade recorded: ${params.instrument} ${params.isLong ? "LONG" : "SHORT"} $${Number(params.notionalUSD) / 1e6} \u2014 tx: ${txHash}`);
|
|
3141
|
+
return { success: true, txHash };
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3144
|
+
if (message.includes("DuplicateFill") || message.includes("already recorded")) {
|
|
3145
|
+
console.log(`Fill already recorded on-chain: ${params.fillId}`);
|
|
3146
|
+
return { success: true };
|
|
3147
|
+
}
|
|
3148
|
+
console.error(`Failed to record perp trade: ${message}`);
|
|
3149
|
+
this.retryQueue.push({
|
|
3150
|
+
params,
|
|
3151
|
+
retries: 0,
|
|
3152
|
+
lastAttempt: Date.now()
|
|
3153
|
+
});
|
|
3154
|
+
return { success: false, error: message };
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Process the retry queue — attempt to re-submit failed recordings.
|
|
3159
|
+
*/
|
|
3160
|
+
async processRetryQueue() {
|
|
3161
|
+
if (this.retryQueue.length === 0) return;
|
|
3162
|
+
const now = Date.now();
|
|
3163
|
+
const toRetry = this.retryQueue.filter(
|
|
3164
|
+
(item) => now - item.lastAttempt >= RETRY_DELAY_MS
|
|
3165
|
+
);
|
|
3166
|
+
for (const item of toRetry) {
|
|
3167
|
+
item.retries++;
|
|
3168
|
+
item.lastAttempt = now;
|
|
3169
|
+
if (item.retries > MAX_RETRIES) {
|
|
3170
|
+
console.error(
|
|
3171
|
+
`Perp trade recording permanently failed after ${MAX_RETRIES} retries: ${item.params.instrument} ${item.params.fillId}`
|
|
3172
|
+
);
|
|
3173
|
+
const idx = this.retryQueue.indexOf(item);
|
|
3174
|
+
if (idx >= 0) this.retryQueue.splice(idx, 1);
|
|
3175
|
+
continue;
|
|
3176
|
+
}
|
|
3177
|
+
console.log(
|
|
3178
|
+
`Retrying perp trade recording (attempt ${item.retries}/${MAX_RETRIES}): ${item.params.instrument}`
|
|
3179
|
+
);
|
|
3180
|
+
const result = await this.submitRecord(item.params);
|
|
3181
|
+
if (result.success) {
|
|
3182
|
+
const idx = this.retryQueue.indexOf(item);
|
|
3183
|
+
if (idx >= 0) this.retryQueue.splice(idx, 1);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
// ============================================================
|
|
3188
|
+
// CONVERSION HELPERS
|
|
3189
|
+
// ============================================================
|
|
3190
|
+
/**
|
|
3191
|
+
* Calculate notional USD from a fill (6-decimal).
|
|
3192
|
+
* notionalUSD = px * sz * 1e6
|
|
3193
|
+
*/
|
|
3194
|
+
calculateNotionalUSD(fill) {
|
|
3195
|
+
const px = parseFloat(fill.px);
|
|
3196
|
+
const sz = parseFloat(fill.sz);
|
|
3197
|
+
return BigInt(Math.round(px * sz * 1e6));
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Calculate fee USD from a fill (6-decimal).
|
|
3201
|
+
* feeUSD = fee * 1e6 (fee is already in USD on Hyperliquid)
|
|
3202
|
+
*/
|
|
3203
|
+
calculateFeeUSD(fill) {
|
|
3204
|
+
const fee = parseFloat(fill.fee);
|
|
3205
|
+
const builderFee = fill.builderFee ? parseFloat(fill.builderFee) : 0;
|
|
3206
|
+
return BigInt(Math.round((fee + builderFee) * 1e6));
|
|
3207
|
+
}
|
|
3208
|
+
};
|
|
3209
|
+
|
|
3210
|
+
// src/perp/onboarding.ts
|
|
3211
|
+
var PerpOnboarding = class {
|
|
3212
|
+
client;
|
|
3213
|
+
signer;
|
|
3214
|
+
config;
|
|
3215
|
+
constructor(client, signer, config) {
|
|
3216
|
+
this.client = client;
|
|
3217
|
+
this.signer = signer;
|
|
3218
|
+
this.config = config;
|
|
3219
|
+
}
|
|
3220
|
+
// ============================================================
|
|
3221
|
+
// BUILDER FEE
|
|
3222
|
+
// ============================================================
|
|
3223
|
+
/**
|
|
3224
|
+
* Check if the user has approved the builder fee.
|
|
3225
|
+
* Builder fee must be approved before orders can include builder fees.
|
|
3226
|
+
*/
|
|
3227
|
+
async isBuilderFeeApproved() {
|
|
3228
|
+
try {
|
|
3229
|
+
const maxFee = await this.client.getMaxBuilderFee(
|
|
3230
|
+
this.signer.getAddress(),
|
|
3231
|
+
this.config.builderAddress
|
|
3232
|
+
);
|
|
3233
|
+
return maxFee >= this.config.builderFeeTenthsBps;
|
|
3234
|
+
} catch {
|
|
3235
|
+
return false;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Approve the builder fee on Hyperliquid.
|
|
3240
|
+
* This is a one-time approval per builder address.
|
|
3241
|
+
*/
|
|
3242
|
+
async approveBuilderFee() {
|
|
3243
|
+
try {
|
|
3244
|
+
const action = {
|
|
3245
|
+
type: "approveBuilderFee",
|
|
3246
|
+
hyperliquidChain: "Mainnet",
|
|
3247
|
+
maxFeeRate: `${this.config.builderFeeTenthsBps / 1e4}%`,
|
|
3248
|
+
builder: this.config.builderAddress,
|
|
3249
|
+
nonce: Number(getNextNonce())
|
|
3250
|
+
};
|
|
3251
|
+
const { signature } = await this.signer.signApproval(action);
|
|
3252
|
+
const resp = await fetch(`${this.config.apiUrl}/exchange`, {
|
|
3253
|
+
method: "POST",
|
|
3254
|
+
headers: { "Content-Type": "application/json" },
|
|
3255
|
+
body: JSON.stringify({
|
|
3256
|
+
action,
|
|
3257
|
+
signature: {
|
|
3258
|
+
r: signature.slice(0, 66),
|
|
3259
|
+
s: `0x${signature.slice(66, 130)}`,
|
|
3260
|
+
v: parseInt(signature.slice(130, 132), 16)
|
|
3261
|
+
},
|
|
3262
|
+
nonce: action.nonce,
|
|
3263
|
+
vaultAddress: null
|
|
3264
|
+
})
|
|
3265
|
+
});
|
|
3266
|
+
if (!resp.ok) {
|
|
3267
|
+
const text = await resp.text();
|
|
3268
|
+
console.error(`Builder fee approval failed: ${resp.status} ${text}`);
|
|
3269
|
+
return false;
|
|
3270
|
+
}
|
|
3271
|
+
console.log(`Builder fee approved: ${this.config.builderFeeTenthsBps / 10} bps for ${this.config.builderAddress}`);
|
|
3272
|
+
return true;
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3275
|
+
console.error(`Builder fee approval failed: ${message}`);
|
|
3276
|
+
return false;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
// ============================================================
|
|
3280
|
+
// BALANCE & REQUIREMENTS
|
|
3281
|
+
// ============================================================
|
|
3282
|
+
/**
|
|
3283
|
+
* Check if the user has sufficient USDC balance on Hyperliquid.
|
|
3284
|
+
* Returns the account equity in USD.
|
|
3285
|
+
*/
|
|
3286
|
+
async checkBalance() {
|
|
3287
|
+
try {
|
|
3288
|
+
const account = await this.client.getAccountSummary(this.signer.getAddress());
|
|
3289
|
+
return {
|
|
3290
|
+
hasBalance: account.totalEquity > 0,
|
|
3291
|
+
equity: account.totalEquity
|
|
3292
|
+
};
|
|
3293
|
+
} catch {
|
|
3294
|
+
return { hasBalance: false, equity: 0 };
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
/**
|
|
3298
|
+
* Verify that the agent's risk universe allows perp trading.
|
|
3299
|
+
* Perps require risk universe >= 2 (Derivatives or higher).
|
|
3300
|
+
*/
|
|
3301
|
+
verifyRiskUniverse(riskUniverse) {
|
|
3302
|
+
if (riskUniverse >= 2) {
|
|
3303
|
+
return {
|
|
3304
|
+
allowed: true,
|
|
3305
|
+
message: `Risk universe ${riskUniverse} allows perp trading`
|
|
3306
|
+
};
|
|
3307
|
+
}
|
|
3308
|
+
return {
|
|
3309
|
+
allowed: false,
|
|
3310
|
+
message: `Risk universe ${riskUniverse} does not allow perp trading. Perps require Derivatives (2) or higher.`
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
// ============================================================
|
|
3314
|
+
// FULL ONBOARDING CHECK
|
|
3315
|
+
// ============================================================
|
|
3316
|
+
/**
|
|
3317
|
+
* Run all onboarding checks and return status.
|
|
3318
|
+
* Does NOT auto-approve — caller must explicitly approve after review.
|
|
3319
|
+
*/
|
|
3320
|
+
async checkOnboardingStatus(riskUniverse) {
|
|
3321
|
+
const riskCheck = this.verifyRiskUniverse(riskUniverse);
|
|
3322
|
+
const balanceCheck = await this.checkBalance();
|
|
3323
|
+
const builderFeeApproved = await this.isBuilderFeeApproved();
|
|
3324
|
+
const ready = riskCheck.allowed && balanceCheck.hasBalance && builderFeeApproved;
|
|
3325
|
+
return {
|
|
3326
|
+
ready,
|
|
3327
|
+
riskUniverseOk: riskCheck.allowed,
|
|
3328
|
+
riskUniverseMessage: riskCheck.message,
|
|
3329
|
+
hasBalance: balanceCheck.hasBalance,
|
|
3330
|
+
equity: balanceCheck.equity,
|
|
3331
|
+
builderFeeApproved,
|
|
3332
|
+
builderAddress: this.config.builderAddress,
|
|
3333
|
+
builderFeeBps: this.config.builderFeeTenthsBps / 10
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
/**
|
|
3337
|
+
* Run full onboarding: check status and auto-approve builder fee if needed.
|
|
3338
|
+
* Returns the final status after all actions.
|
|
3339
|
+
*/
|
|
3340
|
+
async onboard(riskUniverse) {
|
|
3341
|
+
let status = await this.checkOnboardingStatus(riskUniverse);
|
|
3342
|
+
if (!status.riskUniverseOk) {
|
|
3343
|
+
console.error(`Perp onboarding blocked: ${status.riskUniverseMessage}`);
|
|
3344
|
+
return status;
|
|
3345
|
+
}
|
|
3346
|
+
if (!status.hasBalance) {
|
|
3347
|
+
console.warn("No USDC balance on Hyperliquid \u2014 deposit required before trading");
|
|
3348
|
+
return status;
|
|
3349
|
+
}
|
|
3350
|
+
if (!status.builderFeeApproved) {
|
|
3351
|
+
console.log("Approving builder fee...");
|
|
3352
|
+
const approved = await this.approveBuilderFee();
|
|
3353
|
+
if (approved) {
|
|
3354
|
+
status = { ...status, builderFeeApproved: true, ready: true };
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
if (status.ready) {
|
|
3358
|
+
console.log(`Perp onboarding complete \u2014 equity: $${status.equity.toFixed(2)}`);
|
|
3359
|
+
}
|
|
3360
|
+
return status;
|
|
3361
|
+
}
|
|
3362
|
+
};
|
|
3363
|
+
|
|
3364
|
+
// src/perp/funding.ts
|
|
3365
|
+
var import_viem5 = require("viem");
|
|
3366
|
+
var import_chains3 = require("viem/chains");
|
|
3367
|
+
var import_accounts4 = require("viem/accounts");
|
|
3368
|
+
var ERC20_ABI = (0, import_viem5.parseAbi)([
|
|
3369
|
+
"function approve(address spender, uint256 amount) external returns (bool)",
|
|
3370
|
+
"function balanceOf(address account) external view returns (uint256)",
|
|
3371
|
+
"function allowance(address owner, address spender) external view returns (uint256)"
|
|
3372
|
+
]);
|
|
3373
|
+
var TOKEN_MESSENGER_V2_ABI = (0, import_viem5.parseAbi)([
|
|
3374
|
+
"function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken) external returns (uint64 nonce)",
|
|
3375
|
+
"event DepositForBurn(uint64 indexed nonce, address indexed burnToken, uint256 amount, address indexed depositor, bytes32 mintRecipient, uint32 destinationDomain, bytes32 destinationTokenMessenger, bytes32 destinationCaller)"
|
|
3376
|
+
]);
|
|
3377
|
+
var MESSAGE_TRANSMITTER_V2_ABI = (0, import_viem5.parseAbi)([
|
|
3378
|
+
"function receiveMessage(bytes message, bytes attestation) external returns (bool success)",
|
|
3379
|
+
"event MessageSent(bytes message)"
|
|
3380
|
+
]);
|
|
3381
|
+
var CORE_DEPOSIT_WALLET_ABI = (0, import_viem5.parseAbi)([
|
|
3382
|
+
"function deposit(uint256 amount, uint32 destinationDex) external"
|
|
3383
|
+
]);
|
|
3384
|
+
|
|
3385
|
+
// src/runtime.ts
|
|
3386
|
+
var FUNDS_LOW_THRESHOLD = 5e-3;
|
|
3387
|
+
var FUNDS_CRITICAL_THRESHOLD = 1e-3;
|
|
3388
|
+
var AgentRuntime = class {
|
|
3389
|
+
config;
|
|
3390
|
+
client;
|
|
3391
|
+
llm;
|
|
3392
|
+
strategy;
|
|
3393
|
+
executor;
|
|
3394
|
+
riskManager;
|
|
3395
|
+
marketData;
|
|
3396
|
+
vaultManager;
|
|
3397
|
+
relay = null;
|
|
3398
|
+
isRunning = false;
|
|
3399
|
+
mode = "idle";
|
|
3400
|
+
configHash;
|
|
3401
|
+
cycleCount = 0;
|
|
3402
|
+
lastCycleAt = 0;
|
|
3403
|
+
lastPortfolioValue = 0;
|
|
3404
|
+
lastEthBalance = "0";
|
|
3405
|
+
processAlive = true;
|
|
3406
|
+
riskUniverse = 0;
|
|
3407
|
+
allowedTokens = /* @__PURE__ */ new Set();
|
|
3408
|
+
// Perp trading components (null if perp not enabled)
|
|
3409
|
+
perpClient = null;
|
|
3410
|
+
perpSigner = null;
|
|
3411
|
+
perpOrders = null;
|
|
3412
|
+
perpPositions = null;
|
|
3413
|
+
perpWebSocket = null;
|
|
3414
|
+
perpRecorder = null;
|
|
3415
|
+
perpOnboarding = null;
|
|
3416
|
+
perpStrategy = null;
|
|
3417
|
+
// Two-layer perp control:
|
|
3418
|
+
// perpConnected = Hyperliquid infrastructure is initialized (WS, signer, recorder ready)
|
|
3419
|
+
// perpTradingActive = Dedicated perp trading cycle is mandated to run
|
|
3420
|
+
// When perpConnected && !perpTradingActive: agent's strategy can optionally return perp signals
|
|
3421
|
+
// When perpConnected && perpTradingActive: dedicated runPerpCycle() runs every interval
|
|
3422
|
+
perpConnected = false;
|
|
3423
|
+
perpTradingActive = false;
|
|
3424
|
+
constructor(config) {
|
|
3425
|
+
this.config = config;
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3428
|
+
* Initialize the agent runtime
|
|
3429
|
+
*/
|
|
3430
|
+
async initialize() {
|
|
3431
|
+
console.log(`Initializing agent: ${this.config.name} (ID: ${this.config.agentId})`);
|
|
3432
|
+
this.client = new import_sdk2.ExagentClient({
|
|
3433
|
+
privateKey: this.config.privateKey,
|
|
3434
|
+
network: this.config.network
|
|
3435
|
+
});
|
|
3436
|
+
console.log(`Wallet: ${this.client.address}`);
|
|
3437
|
+
const agent = await this.client.registry.getAgent(BigInt(this.config.agentId));
|
|
3438
|
+
if (!agent) {
|
|
3439
|
+
throw new Error(`Agent ID ${this.config.agentId} not found on-chain. Please register first.`);
|
|
3440
|
+
}
|
|
3441
|
+
console.log(`Agent verified: ${agent.name}`);
|
|
3442
|
+
await this.ensureWalletLinked();
|
|
3443
|
+
await this.loadTradingRestrictions();
|
|
3444
|
+
console.log(`Initializing LLM: ${this.config.llm.provider}`);
|
|
3445
|
+
this.llm = await createLLMAdapter(this.config.llm);
|
|
3446
|
+
const llmMeta = this.llm.getMetadata();
|
|
3447
|
+
console.log(`LLM ready: ${llmMeta.provider} (${llmMeta.model})`);
|
|
3448
|
+
await this.syncConfigHash();
|
|
3449
|
+
this.strategy = await loadStrategy();
|
|
3450
|
+
this.executor = new TradeExecutor(this.client, this.config, () => this.getConfigHash());
|
|
3451
|
+
this.riskManager = new RiskManager(this.config.trading);
|
|
3452
|
+
this.marketData = new MarketDataService(this.getRpcUrl());
|
|
3453
|
+
await this.initializeVaultManager();
|
|
3454
|
+
await this.initializePerp();
|
|
3455
|
+
await this.initializeRelay();
|
|
3456
|
+
console.log("Agent initialized successfully");
|
|
3457
|
+
}
|
|
3458
|
+
/**
|
|
3459
|
+
* Initialize the relay client for command center connectivity
|
|
3460
|
+
*/
|
|
3461
|
+
async initializeRelay() {
|
|
3462
|
+
const relayConfig = this.config.relay;
|
|
3463
|
+
const relayEnabled = process.env.EXAGENT_RELAY_ENABLED !== "false";
|
|
3464
|
+
if (!relayConfig?.enabled || !relayEnabled) {
|
|
3465
|
+
console.log("Relay: Disabled");
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
const apiUrl = process.env.EXAGENT_API_URL || relayConfig.apiUrl;
|
|
3469
|
+
if (!apiUrl) {
|
|
3470
|
+
console.log("Relay: No API URL configured, skipping");
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
this.relay = new RelayClient({
|
|
3474
|
+
agentId: String(this.config.agentId),
|
|
3475
|
+
privateKey: this.config.privateKey,
|
|
3476
|
+
relay: {
|
|
3477
|
+
...relayConfig,
|
|
3478
|
+
apiUrl
|
|
3479
|
+
},
|
|
3480
|
+
onCommand: (cmd) => this.handleCommand(cmd)
|
|
3481
|
+
});
|
|
3482
|
+
try {
|
|
3483
|
+
await this.relay.connect();
|
|
3484
|
+
console.log("Relay: Connected to command center");
|
|
3485
|
+
this.sendRelayStatus();
|
|
3486
|
+
} catch (error) {
|
|
3487
|
+
console.warn(
|
|
3488
|
+
"Relay: Failed to connect (agent will work locally):",
|
|
3489
|
+
error instanceof Error ? error.message : error
|
|
3490
|
+
);
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* Initialize the vault manager based on config
|
|
3495
|
+
*/
|
|
3496
|
+
async initializeVaultManager() {
|
|
3497
|
+
const vaultConfig = this.config.vault || { policy: "disabled", preferVaultTrading: false };
|
|
3498
|
+
this.vaultManager = new VaultManager({
|
|
3499
|
+
agentId: BigInt(this.config.agentId),
|
|
3500
|
+
agentName: this.config.name,
|
|
3501
|
+
network: this.config.network,
|
|
3502
|
+
walletKey: this.config.privateKey,
|
|
3503
|
+
vaultConfig
|
|
3504
|
+
});
|
|
3505
|
+
console.log(`Vault policy: ${vaultConfig.policy}`);
|
|
3506
|
+
const status = await this.vaultManager.getVaultStatus();
|
|
3507
|
+
if (status.hasVault) {
|
|
3508
|
+
console.log(`Vault exists: ${status.vaultAddress}`);
|
|
3509
|
+
console.log(`Vault TVL: ${Number(status.totalAssets) / 1e6} USDC`);
|
|
3510
|
+
} else {
|
|
3511
|
+
console.log("No vault exists for this agent");
|
|
3512
|
+
if (vaultConfig.policy === "manual") {
|
|
3513
|
+
console.log("Vault creation is manual \u2014 use the command center to create one");
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* Initialize Hyperliquid perp trading components.
|
|
3519
|
+
* Only initializes if perp is enabled in config AND risk universe >= 2.
|
|
3520
|
+
*/
|
|
3521
|
+
async initializePerp() {
|
|
3522
|
+
const perpConfig = this.config.perp;
|
|
3523
|
+
if (!perpConfig?.enabled) {
|
|
3524
|
+
console.log("Perp trading: Disabled");
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
if (this.riskUniverse < 2) {
|
|
3528
|
+
console.warn(`Perp trading: Blocked by risk universe ${this.riskUniverse} (need >= 2)`);
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
try {
|
|
3532
|
+
const config = {
|
|
3533
|
+
enabled: true,
|
|
3534
|
+
apiUrl: perpConfig.apiUrl || "https://api.hyperliquid.xyz",
|
|
3535
|
+
wsUrl: perpConfig.wsUrl || "wss://api.hyperliquid.xyz/ws",
|
|
3536
|
+
builderAddress: perpConfig.builderAddress,
|
|
3537
|
+
builderFeeTenthsBps: perpConfig.builderFeeTenthsBps ?? 100,
|
|
3538
|
+
maxLeverage: perpConfig.maxLeverage ?? 10,
|
|
3539
|
+
maxNotionalUSD: perpConfig.maxNotionalUSD ?? 5e4,
|
|
3540
|
+
allowedInstruments: perpConfig.allowedInstruments
|
|
3541
|
+
};
|
|
3542
|
+
this.perpClient = new HyperliquidClient(config);
|
|
3543
|
+
const perpKey = perpConfig.perpRelayerKey || this.config.privateKey;
|
|
3544
|
+
const account = (0, import_accounts5.privateKeyToAccount)(perpKey);
|
|
3545
|
+
const walletClient = (0, import_viem6.createWalletClient)({
|
|
3546
|
+
chain: { id: 42161, name: "Arbitrum", nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: ["https://arb1.arbitrum.io/rpc"] } } },
|
|
3547
|
+
transport: (0, import_viem6.http)("https://arb1.arbitrum.io/rpc"),
|
|
3548
|
+
account
|
|
3549
|
+
});
|
|
3550
|
+
this.perpSigner = new HyperliquidSigner(walletClient);
|
|
3551
|
+
this.perpOrders = new OrderManager(this.perpClient, this.perpSigner, config);
|
|
3552
|
+
this.perpPositions = new PositionManager(this.perpClient, this.perpSigner.getAddress());
|
|
3553
|
+
this.perpOnboarding = new PerpOnboarding(this.perpClient, this.perpSigner, config);
|
|
3554
|
+
const recorderKey = perpConfig.perpRelayerKey || this.config.privateKey;
|
|
3555
|
+
this.perpRecorder = new PerpTradeRecorder({
|
|
3556
|
+
privateKey: recorderKey,
|
|
3557
|
+
rpcUrl: this.getRpcUrl(),
|
|
3558
|
+
agentId: BigInt(this.config.agentId),
|
|
3559
|
+
configHash: this.configHash
|
|
3560
|
+
});
|
|
3561
|
+
this.perpWebSocket = new HyperliquidWebSocket(config, this.perpSigner.getAddress(), this.perpClient);
|
|
3562
|
+
this.perpWebSocket.onFillReceived(async (fill) => {
|
|
3563
|
+
console.log(`Perp fill: ${fill.coin} ${fill.side === "B" ? "LONG" : "SHORT"} ${fill.sz}@${fill.px}`);
|
|
3564
|
+
const result = await this.perpRecorder.recordFill(fill);
|
|
3565
|
+
if (result.success) {
|
|
3566
|
+
this.relay?.sendMessage(
|
|
3567
|
+
"perp_fill",
|
|
3568
|
+
"success",
|
|
3569
|
+
"Perp Fill",
|
|
3570
|
+
`${fill.coin} ${fill.side === "B" ? "LONG" : "SHORT"} ${fill.sz} @ $${fill.px}`,
|
|
3571
|
+
{ instrument: fill.coin, side: fill.side, size: fill.sz, price: fill.px, txHash: result.txHash }
|
|
3572
|
+
);
|
|
3573
|
+
}
|
|
3574
|
+
});
|
|
3575
|
+
this.perpWebSocket.onLiquidationDetected((instrument, size) => {
|
|
3576
|
+
console.error(`LIQUIDATION: ${instrument} position (${size}) was liquidated`);
|
|
3577
|
+
this.relay?.sendMessage(
|
|
3578
|
+
"perp_liquidation_warning",
|
|
3579
|
+
"error",
|
|
3580
|
+
"Position Liquidated",
|
|
3581
|
+
`${instrument} position of ${Math.abs(size)} was liquidated.`,
|
|
3582
|
+
{ instrument, size }
|
|
3583
|
+
);
|
|
3584
|
+
});
|
|
3585
|
+
this.perpWebSocket.onFundingReceived((funding) => {
|
|
3586
|
+
const amount = parseFloat(funding.usdc);
|
|
3587
|
+
if (Math.abs(amount) > 0.01) {
|
|
3588
|
+
this.relay?.sendMessage(
|
|
3589
|
+
"perp_funding",
|
|
3590
|
+
"info",
|
|
3591
|
+
"Funding Payment",
|
|
3592
|
+
`${funding.coin}: ${amount > 0 ? "+" : ""}$${amount.toFixed(4)}`,
|
|
3593
|
+
{ instrument: funding.coin, amount: funding.usdc, rate: funding.fundingRate }
|
|
3594
|
+
);
|
|
3595
|
+
}
|
|
3596
|
+
});
|
|
3597
|
+
const onboardingStatus = await this.perpOnboarding.onboard(this.riskUniverse);
|
|
3598
|
+
if (!onboardingStatus.ready) {
|
|
3599
|
+
console.warn(`Perp onboarding incomplete \u2014 trading will be limited`);
|
|
3600
|
+
if (!onboardingStatus.hasBalance) {
|
|
3601
|
+
console.warn(" No USDC balance on Hyperliquid \u2014 deposit required");
|
|
3602
|
+
}
|
|
3603
|
+
if (!onboardingStatus.builderFeeApproved) {
|
|
3604
|
+
console.warn(" Builder fee not approved \u2014 orders may fail");
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
try {
|
|
3608
|
+
await this.perpWebSocket.connect();
|
|
3609
|
+
console.log("Perp WebSocket: Connected");
|
|
3610
|
+
} catch (error) {
|
|
3611
|
+
console.warn("Perp WebSocket: Failed to connect (will retry):", error instanceof Error ? error.message : error);
|
|
3612
|
+
}
|
|
3613
|
+
this.perpConnected = true;
|
|
3614
|
+
console.log(`Hyperliquid: Connected (${config.allowedInstruments?.join(", ") || "all instruments"})`);
|
|
3615
|
+
console.log(` Builder: ${config.builderAddress} (${config.builderFeeTenthsBps / 10} bps)`);
|
|
3616
|
+
console.log(` Max leverage: ${config.maxLeverage}x, Max notional: $${config.maxNotionalUSD.toLocaleString()}`);
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3619
|
+
console.error(`Perp initialization failed: ${message}`);
|
|
3620
|
+
console.warn("Perp trading will be disabled for this session");
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
/**
|
|
3624
|
+
* Ensure the current wallet is linked to the agent.
|
|
3625
|
+
* If the trading wallet differs from the owner, enters a recovery loop
|
|
3626
|
+
* that waits for the owner to link it from the website.
|
|
3627
|
+
*/
|
|
3628
|
+
async ensureWalletLinked() {
|
|
3629
|
+
const agentId = BigInt(this.config.agentId);
|
|
3630
|
+
const address = this.client.address;
|
|
3631
|
+
const isLinked = await this.client.registry.isLinkedWallet(agentId, address);
|
|
3632
|
+
if (!isLinked) {
|
|
3633
|
+
console.log("Wallet not linked, linking now...");
|
|
3634
|
+
const agent = await this.client.registry.getAgent(agentId);
|
|
3635
|
+
if (agent?.owner.toLowerCase() !== address.toLowerCase()) {
|
|
3636
|
+
const ccUrl = `https://exagent.io/agents/${encodeURIComponent(this.config.name)}/command-center`;
|
|
3637
|
+
const nonce = await this.client.registry.getNonce(address);
|
|
3638
|
+
const linkMessage = import_sdk2.ExagentRegistry.generateLinkMessage(
|
|
3639
|
+
address,
|
|
3640
|
+
agentId,
|
|
2168
3641
|
nonce
|
|
2169
3642
|
);
|
|
2170
3643
|
const linkSignature = await this.client.signMessage({ raw: linkMessage });
|
|
@@ -2266,10 +3739,10 @@ var AgentRuntime = class {
|
|
|
2266
3739
|
const message = error instanceof Error ? error.message : String(error);
|
|
2267
3740
|
if (message.includes("insufficient funds") || message.includes("gas") || message.includes("intrinsic gas too low") || message.includes("exceeds the balance")) {
|
|
2268
3741
|
const ccUrl = `https://exagent.io/agents/${encodeURIComponent(this.config.name)}/command-center`;
|
|
2269
|
-
const chain =
|
|
2270
|
-
const publicClientInstance = (0,
|
|
3742
|
+
const chain = import_chains4.base;
|
|
3743
|
+
const publicClientInstance = (0, import_viem6.createPublicClient)({
|
|
2271
3744
|
chain,
|
|
2272
|
-
transport: (0,
|
|
3745
|
+
transport: (0, import_viem6.http)(this.getRpcUrl())
|
|
2273
3746
|
});
|
|
2274
3747
|
console.log("");
|
|
2275
3748
|
console.log("=== ETH NEEDED FOR GAS ===");
|
|
@@ -2480,6 +3953,131 @@ var AgentRuntime = class {
|
|
|
2480
3953
|
}
|
|
2481
3954
|
break;
|
|
2482
3955
|
}
|
|
3956
|
+
case "enable_hyperliquid":
|
|
3957
|
+
if (this.perpConnected) {
|
|
3958
|
+
this.relay?.sendCommandResult(cmd.id, true, "Hyperliquid already connected");
|
|
3959
|
+
break;
|
|
3960
|
+
}
|
|
3961
|
+
if (!this.config.perp?.enabled) {
|
|
3962
|
+
this.relay?.sendCommandResult(cmd.id, false, "Perp trading not configured in agent config");
|
|
3963
|
+
break;
|
|
3964
|
+
}
|
|
3965
|
+
if (this.riskUniverse < 2) {
|
|
3966
|
+
this.relay?.sendCommandResult(cmd.id, false, `Risk universe ${this.riskUniverse} too low (need >= 2)`);
|
|
3967
|
+
break;
|
|
3968
|
+
}
|
|
3969
|
+
try {
|
|
3970
|
+
await this.initializePerp();
|
|
3971
|
+
if (this.perpConnected) {
|
|
3972
|
+
this.relay?.sendCommandResult(cmd.id, true, "Hyperliquid connected");
|
|
3973
|
+
this.relay?.sendMessage(
|
|
3974
|
+
"system",
|
|
3975
|
+
"success",
|
|
3976
|
+
"Hyperliquid Enabled",
|
|
3977
|
+
'Hyperliquid infrastructure connected. Agent can now include perp signals in its strategy. Use "Start Perp Trading" to mandate a dedicated perp cycle.'
|
|
3978
|
+
);
|
|
3979
|
+
} else {
|
|
3980
|
+
this.relay?.sendCommandResult(cmd.id, false, "Failed to connect to Hyperliquid");
|
|
3981
|
+
}
|
|
3982
|
+
} catch (error) {
|
|
3983
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
3984
|
+
this.relay?.sendCommandResult(cmd.id, false, `Hyperliquid init failed: ${msg}`);
|
|
3985
|
+
}
|
|
3986
|
+
this.sendRelayStatus();
|
|
3987
|
+
break;
|
|
3988
|
+
case "disable_hyperliquid":
|
|
3989
|
+
if (!this.perpConnected) {
|
|
3990
|
+
this.relay?.sendCommandResult(cmd.id, true, "Hyperliquid already disconnected");
|
|
3991
|
+
break;
|
|
3992
|
+
}
|
|
3993
|
+
this.perpTradingActive = false;
|
|
3994
|
+
if (this.perpWebSocket) {
|
|
3995
|
+
this.perpWebSocket.disconnect();
|
|
3996
|
+
}
|
|
3997
|
+
if (this.perpRecorder) {
|
|
3998
|
+
this.perpRecorder.stop();
|
|
3999
|
+
}
|
|
4000
|
+
this.perpConnected = false;
|
|
4001
|
+
console.log("Hyperliquid disabled via command center");
|
|
4002
|
+
this.relay?.sendCommandResult(cmd.id, true, "Hyperliquid disconnected");
|
|
4003
|
+
this.relay?.sendMessage(
|
|
4004
|
+
"system",
|
|
4005
|
+
"info",
|
|
4006
|
+
"Hyperliquid Disabled",
|
|
4007
|
+
"Hyperliquid infrastructure disconnected. Agent will trade spot only."
|
|
4008
|
+
);
|
|
4009
|
+
this.sendRelayStatus();
|
|
4010
|
+
break;
|
|
4011
|
+
case "start_perp_trading":
|
|
4012
|
+
if (!this.perpConnected) {
|
|
4013
|
+
this.relay?.sendCommandResult(cmd.id, false, "Hyperliquid not connected. Enable Hyperliquid first.");
|
|
4014
|
+
break;
|
|
4015
|
+
}
|
|
4016
|
+
if (this.perpTradingActive) {
|
|
4017
|
+
this.relay?.sendCommandResult(cmd.id, true, "Perp trading already active");
|
|
4018
|
+
break;
|
|
4019
|
+
}
|
|
4020
|
+
this.perpTradingActive = true;
|
|
4021
|
+
if (this.mode !== "trading") {
|
|
4022
|
+
this.mode = "trading";
|
|
4023
|
+
this.isRunning = true;
|
|
4024
|
+
}
|
|
4025
|
+
console.log("Perp trading mandated via command center");
|
|
4026
|
+
this.relay?.sendCommandResult(cmd.id, true, "Perp trading cycle active");
|
|
4027
|
+
this.relay?.sendMessage(
|
|
4028
|
+
"system",
|
|
4029
|
+
"success",
|
|
4030
|
+
"Perp Trading Active",
|
|
4031
|
+
"Dedicated perp trading cycle is now running every interval."
|
|
4032
|
+
);
|
|
4033
|
+
this.sendRelayStatus();
|
|
4034
|
+
break;
|
|
4035
|
+
case "stop_perp_trading":
|
|
4036
|
+
if (!this.perpTradingActive) {
|
|
4037
|
+
this.relay?.sendCommandResult(cmd.id, true, "Perp trading already stopped");
|
|
4038
|
+
break;
|
|
4039
|
+
}
|
|
4040
|
+
this.perpTradingActive = false;
|
|
4041
|
+
console.log("Perp trading cycle stopped via command center");
|
|
4042
|
+
this.relay?.sendCommandResult(cmd.id, true, "Perp trading cycle stopped");
|
|
4043
|
+
this.relay?.sendMessage(
|
|
4044
|
+
"system",
|
|
4045
|
+
"info",
|
|
4046
|
+
"Perp Trading Stopped",
|
|
4047
|
+
"Dedicated perp cycle stopped. Hyperliquid remains connected \u2014 strategy can still include perp signals."
|
|
4048
|
+
);
|
|
4049
|
+
this.sendRelayStatus();
|
|
4050
|
+
break;
|
|
4051
|
+
case "update_perp_params": {
|
|
4052
|
+
const perpParams = cmd.params || {};
|
|
4053
|
+
let perpUpdated = false;
|
|
4054
|
+
if (this.config.perp && perpParams.maxLeverage !== void 0) {
|
|
4055
|
+
const val = Number(perpParams.maxLeverage);
|
|
4056
|
+
if (val >= 1 && val <= 50) {
|
|
4057
|
+
this.config.perp.maxLeverage = val;
|
|
4058
|
+
perpUpdated = true;
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
if (this.config.perp && perpParams.maxNotionalUSD !== void 0) {
|
|
4062
|
+
const val = Number(perpParams.maxNotionalUSD);
|
|
4063
|
+
if (val >= 100) {
|
|
4064
|
+
this.config.perp.maxNotionalUSD = val;
|
|
4065
|
+
perpUpdated = true;
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
if (perpUpdated) {
|
|
4069
|
+
this.relay?.sendCommandResult(cmd.id, true, "Perp parameters updated");
|
|
4070
|
+
this.relay?.sendMessage(
|
|
4071
|
+
"config_updated",
|
|
4072
|
+
"info",
|
|
4073
|
+
"Perp Params Updated",
|
|
4074
|
+
`Max leverage: ${this.config.perp?.maxLeverage}x, Max notional: $${this.config.perp?.maxNotionalUSD?.toLocaleString()}`
|
|
4075
|
+
);
|
|
4076
|
+
} else {
|
|
4077
|
+
this.relay?.sendCommandResult(cmd.id, false, "No valid perp parameters provided");
|
|
4078
|
+
}
|
|
4079
|
+
break;
|
|
4080
|
+
}
|
|
2483
4081
|
case "refresh_status":
|
|
2484
4082
|
this.sendRelayStatus();
|
|
2485
4083
|
this.relay?.sendCommandResult(cmd.id, true, "Status refreshed");
|
|
@@ -2534,8 +4132,33 @@ var AgentRuntime = class {
|
|
|
2534
4132
|
policy: vaultConfig.policy,
|
|
2535
4133
|
hasVault: false,
|
|
2536
4134
|
vaultAddress: null
|
|
2537
|
-
}
|
|
4135
|
+
},
|
|
4136
|
+
perp: this.perpConnected ? {
|
|
4137
|
+
enabled: true,
|
|
4138
|
+
trading: this.perpTradingActive,
|
|
4139
|
+
equity: 0,
|
|
4140
|
+
unrealizedPnl: 0,
|
|
4141
|
+
marginUsed: 0,
|
|
4142
|
+
openPositions: 0,
|
|
4143
|
+
effectiveLeverage: 0,
|
|
4144
|
+
pendingRecords: this.perpRecorder?.pendingRetries ?? 0
|
|
4145
|
+
} : void 0
|
|
2538
4146
|
};
|
|
4147
|
+
if (this.perpConnected && this.perpPositions && status.perp) {
|
|
4148
|
+
this.perpPositions.getAccountSummary().then((account) => {
|
|
4149
|
+
if (status.perp) {
|
|
4150
|
+
status.perp.equity = account.totalEquity;
|
|
4151
|
+
status.perp.unrealizedPnl = account.totalUnrealizedPnl;
|
|
4152
|
+
status.perp.marginUsed = account.totalMarginUsed;
|
|
4153
|
+
status.perp.effectiveLeverage = account.effectiveLeverage;
|
|
4154
|
+
}
|
|
4155
|
+
}).catch(() => {
|
|
4156
|
+
});
|
|
4157
|
+
this.perpPositions.getPositionCount().then((count) => {
|
|
4158
|
+
if (status.perp) status.perp.openPositions = count;
|
|
4159
|
+
}).catch(() => {
|
|
4160
|
+
});
|
|
4161
|
+
}
|
|
2539
4162
|
this.relay.sendHeartbeat(status);
|
|
2540
4163
|
}
|
|
2541
4164
|
/**
|
|
@@ -2634,8 +4257,75 @@ var AgentRuntime = class {
|
|
|
2634
4257
|
const postNativeEthBal = postTradeData.balances[NATIVE_ETH.toLowerCase()] || BigInt(0);
|
|
2635
4258
|
this.lastEthBalance = (Number(postNativeEthBal) / 1e18).toFixed(6);
|
|
2636
4259
|
}
|
|
4260
|
+
if (this.perpConnected && this.perpTradingActive) {
|
|
4261
|
+
try {
|
|
4262
|
+
await this.runPerpCycle();
|
|
4263
|
+
} catch (error) {
|
|
4264
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4265
|
+
console.error("Error in perp cycle:", message);
|
|
4266
|
+
this.relay?.sendMessage("system", "error", "Perp Cycle Error", message);
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
2637
4269
|
this.sendRelayStatus();
|
|
2638
4270
|
}
|
|
4271
|
+
/**
|
|
4272
|
+
* Run a single perp trading cycle.
|
|
4273
|
+
* Fetches market data, positions, calls perp strategy, applies risk filters, executes.
|
|
4274
|
+
* Fills arrive async via WebSocket and are auto-recorded on Base.
|
|
4275
|
+
*/
|
|
4276
|
+
async runPerpCycle() {
|
|
4277
|
+
if (!this.perpClient || !this.perpPositions || !this.perpOrders || !this.perpConnected) return;
|
|
4278
|
+
const perpConfig = this.config.perp;
|
|
4279
|
+
if (!perpConfig?.enabled) return;
|
|
4280
|
+
console.log(" [PERP] Running perp cycle...");
|
|
4281
|
+
const [positions, account] = await Promise.all([
|
|
4282
|
+
this.perpPositions.getPositions(true),
|
|
4283
|
+
this.perpPositions.getAccountSummary(true)
|
|
4284
|
+
]);
|
|
4285
|
+
console.log(` [PERP] Equity: $${account.totalEquity.toFixed(2)}, Positions: ${positions.length}, Leverage: ${account.effectiveLeverage.toFixed(1)}x`);
|
|
4286
|
+
const dangerousPositions = await this.perpPositions.getDangerousPositions(0.7);
|
|
4287
|
+
for (const pos of dangerousPositions) {
|
|
4288
|
+
console.warn(` [PERP] WARNING: ${pos.instrument} near liquidation`);
|
|
4289
|
+
this.relay?.sendMessage(
|
|
4290
|
+
"perp_liquidation_warning",
|
|
4291
|
+
"warning",
|
|
4292
|
+
"Near Liquidation",
|
|
4293
|
+
`${pos.instrument} ${pos.size > 0 ? "LONG" : "SHORT"} \u2014 close to liquidation price $${pos.liquidationPrice.toFixed(2)}`,
|
|
4294
|
+
{ instrument: pos.instrument, liquidationPrice: pos.liquidationPrice, markPrice: pos.markPrice }
|
|
4295
|
+
);
|
|
4296
|
+
}
|
|
4297
|
+
const instruments = perpConfig.allowedInstruments || ["ETH", "BTC", "SOL"];
|
|
4298
|
+
const marketData = await this.perpClient.getMarketData(instruments);
|
|
4299
|
+
if (!this.perpStrategy) {
|
|
4300
|
+
return;
|
|
4301
|
+
}
|
|
4302
|
+
let signals;
|
|
4303
|
+
try {
|
|
4304
|
+
signals = await this.perpStrategy(marketData, positions, account, this.llm, this.config);
|
|
4305
|
+
} catch (error) {
|
|
4306
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4307
|
+
console.error(" [PERP] Strategy error:", message);
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
4310
|
+
console.log(` [PERP] Strategy generated ${signals.length} signals`);
|
|
4311
|
+
const maxLeverage = perpConfig.maxLeverage ?? 10;
|
|
4312
|
+
const maxNotionalUSD = perpConfig.maxNotionalUSD ?? 5e4;
|
|
4313
|
+
const filteredSignals = this.riskManager.filterPerpSignals(signals, positions, account, maxLeverage, maxNotionalUSD);
|
|
4314
|
+
console.log(` [PERP] ${filteredSignals.length} signals passed risk checks`);
|
|
4315
|
+
for (const signal of filteredSignals) {
|
|
4316
|
+
if (signal.action === "hold") continue;
|
|
4317
|
+
if (signal.price === 0) {
|
|
4318
|
+
const md = marketData.find((m) => m.instrument === signal.instrument);
|
|
4319
|
+
if (md) signal.price = md.midPrice;
|
|
4320
|
+
}
|
|
4321
|
+
const result = await this.perpOrders.placeOrder(signal);
|
|
4322
|
+
if (result.success) {
|
|
4323
|
+
console.log(` [PERP] Order placed: ${signal.instrument} ${signal.action} \u2014 ${result.status}`);
|
|
4324
|
+
} else {
|
|
4325
|
+
console.warn(` [PERP] Order failed: ${signal.instrument} ${signal.action} \u2014 ${result.error}`);
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
2639
4329
|
/**
|
|
2640
4330
|
* Check if ETH balance is below threshold and notify.
|
|
2641
4331
|
* Returns true if trading should continue, false if ETH is critically low.
|
|
@@ -2681,6 +4371,12 @@ var AgentRuntime = class {
|
|
|
2681
4371
|
this.isRunning = false;
|
|
2682
4372
|
this.processAlive = false;
|
|
2683
4373
|
this.mode = "idle";
|
|
4374
|
+
if (this.perpWebSocket) {
|
|
4375
|
+
this.perpWebSocket.disconnect();
|
|
4376
|
+
}
|
|
4377
|
+
if (this.perpRecorder) {
|
|
4378
|
+
this.perpRecorder.stop();
|
|
4379
|
+
}
|
|
2684
4380
|
if (this.relay) {
|
|
2685
4381
|
this.relay.disconnect();
|
|
2686
4382
|
}
|
|
@@ -2830,7 +4526,7 @@ var AgentRuntime = class {
|
|
|
2830
4526
|
};
|
|
2831
4527
|
|
|
2832
4528
|
// src/cli.ts
|
|
2833
|
-
var
|
|
4529
|
+
var import_accounts6 = require("viem/accounts");
|
|
2834
4530
|
|
|
2835
4531
|
// src/secure-env.ts
|
|
2836
4532
|
var crypto = __toESM(require("crypto"));
|
|
@@ -3093,8 +4789,8 @@ async function checkFirstRunSetup(configPath) {
|
|
|
3093
4789
|
if (walletSetup === "generate") {
|
|
3094
4790
|
console.log("[WALLET] Generating a new wallet for your agent...");
|
|
3095
4791
|
console.log("");
|
|
3096
|
-
const generatedKey = (0,
|
|
3097
|
-
const account = (0,
|
|
4792
|
+
const generatedKey = (0, import_accounts6.generatePrivateKey)();
|
|
4793
|
+
const account = (0, import_accounts6.privateKeyToAccount)(generatedKey);
|
|
3098
4794
|
privateKey = generatedKey;
|
|
3099
4795
|
walletAddress = account.address;
|
|
3100
4796
|
console.log(" New wallet created!");
|
|
@@ -3124,7 +4820,7 @@ async function checkFirstRunSetup(configPath) {
|
|
|
3124
4820
|
process.exit(1);
|
|
3125
4821
|
}
|
|
3126
4822
|
try {
|
|
3127
|
-
const account = (0,
|
|
4823
|
+
const account = (0, import_accounts6.privateKeyToAccount)(privateKey);
|
|
3128
4824
|
walletAddress = account.address;
|
|
3129
4825
|
console.log("");
|
|
3130
4826
|
console.log(` Wallet address: ${walletAddress}`);
|