openbroker 1.3.2 → 1.4.0
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/CHANGELOG.md +12 -0
- package/dist/auto/audit.d.ts +57 -0
- package/dist/auto/audit.d.ts.map +1 -0
- package/dist/auto/audit.js +407 -0
- package/dist/auto/cli.d.ts +2 -0
- package/dist/auto/cli.d.ts.map +1 -0
- package/dist/auto/cli.js +423 -0
- package/dist/auto/events.d.ts +11 -0
- package/dist/auto/events.d.ts.map +1 -0
- package/dist/auto/events.js +36 -0
- package/dist/auto/examples/dca.d.ts +4 -0
- package/dist/auto/examples/dca.d.ts.map +1 -0
- package/dist/auto/examples/dca.js +60 -0
- package/dist/auto/examples/funding-arb.d.ts +4 -0
- package/dist/auto/examples/funding-arb.d.ts.map +1 -0
- package/dist/auto/examples/funding-arb.js +81 -0
- package/dist/auto/examples/grid.d.ts +4 -0
- package/dist/auto/examples/grid.d.ts.map +1 -0
- package/dist/auto/examples/grid.js +114 -0
- package/dist/auto/examples/mm-maker.d.ts +4 -0
- package/dist/auto/examples/mm-maker.d.ts.map +1 -0
- package/dist/auto/examples/mm-maker.js +131 -0
- package/dist/auto/examples/mm-spread.d.ts +4 -0
- package/dist/auto/examples/mm-spread.d.ts.map +1 -0
- package/dist/auto/examples/mm-spread.js +119 -0
- package/dist/auto/examples/price-alert.d.ts +4 -0
- package/dist/auto/examples/price-alert.d.ts.map +1 -0
- package/dist/auto/examples/price-alert.js +85 -0
- package/dist/auto/keep-awake.d.ts +11 -0
- package/dist/auto/keep-awake.d.ts.map +1 -0
- package/dist/auto/keep-awake.js +70 -0
- package/dist/auto/loader.d.ts +22 -0
- package/dist/auto/loader.d.ts.map +1 -0
- package/dist/auto/loader.js +127 -0
- package/dist/auto/prune.d.ts +40 -0
- package/dist/auto/prune.d.ts.map +1 -0
- package/dist/auto/prune.js +204 -0
- package/dist/auto/registry.d.ts +24 -0
- package/dist/auto/registry.d.ts.map +1 -0
- package/dist/auto/registry.js +93 -0
- package/dist/auto/report.d.ts +3 -0
- package/dist/auto/report.d.ts.map +1 -0
- package/dist/auto/report.js +385 -0
- package/dist/auto/runtime.d.ts +33 -0
- package/dist/auto/runtime.d.ts.map +1 -0
- package/dist/auto/runtime.js +844 -0
- package/dist/auto/types.d.ts +236 -0
- package/dist/auto/types.d.ts.map +1 -0
- package/dist/auto/types.js +3 -0
- package/dist/core/client.d.ts +684 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +2040 -0
- package/dist/core/config.d.ts +22 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +143 -0
- package/dist/core/types.d.ts +221 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/utils.d.ts +61 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +142 -0
- package/dist/core/ws.d.ts +121 -0
- package/dist/core/ws.d.ts.map +1 -0
- package/dist/core/ws.js +222 -0
- package/dist/info/account.d.ts +3 -0
- package/dist/info/account.d.ts.map +1 -0
- package/dist/info/account.js +198 -0
- package/dist/info/all-markets.d.ts +3 -0
- package/dist/info/all-markets.d.ts.map +1 -0
- package/dist/info/all-markets.js +272 -0
- package/dist/info/candles.d.ts +3 -0
- package/dist/info/candles.d.ts.map +1 -0
- package/dist/info/candles.js +120 -0
- package/dist/info/fees.d.ts +3 -0
- package/dist/info/fees.d.ts.map +1 -0
- package/dist/info/fees.js +87 -0
- package/dist/info/fills.d.ts +3 -0
- package/dist/info/fills.d.ts.map +1 -0
- package/dist/info/fills.js +105 -0
- package/dist/info/funding-history.d.ts +3 -0
- package/dist/info/funding-history.d.ts.map +1 -0
- package/dist/info/funding-history.js +98 -0
- package/dist/info/funding-scan.d.ts +3 -0
- package/dist/info/funding-scan.d.ts.map +1 -0
- package/dist/info/funding-scan.js +178 -0
- package/dist/info/funding.d.ts +3 -0
- package/dist/info/funding.d.ts.map +1 -0
- package/dist/info/funding.js +158 -0
- package/dist/info/markets.d.ts +3 -0
- package/dist/info/markets.d.ts.map +1 -0
- package/dist/info/markets.js +178 -0
- package/dist/info/order-status.d.ts +3 -0
- package/dist/info/order-status.d.ts.map +1 -0
- package/dist/info/order-status.js +85 -0
- package/dist/info/orders.d.ts +3 -0
- package/dist/info/orders.d.ts.map +1 -0
- package/dist/info/orders.js +162 -0
- package/dist/info/outcomes.d.ts +3 -0
- package/dist/info/outcomes.d.ts.map +1 -0
- package/dist/info/outcomes.js +175 -0
- package/dist/info/positions.d.ts +3 -0
- package/dist/info/positions.d.ts.map +1 -0
- package/dist/info/positions.js +127 -0
- package/dist/info/rate-limit.d.ts +3 -0
- package/dist/info/rate-limit.d.ts.map +1 -0
- package/dist/info/rate-limit.js +58 -0
- package/dist/info/search-markets.d.ts +3 -0
- package/dist/info/search-markets.d.ts.map +1 -0
- package/dist/info/search-markets.js +296 -0
- package/dist/info/spot.d.ts +3 -0
- package/dist/info/spot.d.ts.map +1 -0
- package/dist/info/spot.js +192 -0
- package/dist/info/trades.d.ts +3 -0
- package/dist/info/trades.d.ts.map +1 -0
- package/dist/info/trades.js +97 -0
- package/dist/lib.d.ts +14 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +17 -0
- package/dist/operations/bracket.d.ts +28 -0
- package/dist/operations/bracket.d.ts.map +1 -0
- package/dist/operations/bracket.js +266 -0
- package/dist/operations/cancel.d.ts +3 -0
- package/dist/operations/cancel.d.ts.map +1 -0
- package/dist/operations/cancel.js +107 -0
- package/dist/operations/chase.d.ts +25 -0
- package/dist/operations/chase.d.ts.map +1 -0
- package/dist/operations/chase.js +215 -0
- package/dist/operations/limit-order.d.ts +3 -0
- package/dist/operations/limit-order.d.ts.map +1 -0
- package/dist/operations/limit-order.js +144 -0
- package/dist/operations/market-order.d.ts +3 -0
- package/dist/operations/market-order.d.ts.map +1 -0
- package/dist/operations/market-order.js +153 -0
- package/dist/operations/outcome-order.d.ts +3 -0
- package/dist/operations/outcome-order.d.ts.map +1 -0
- package/dist/operations/outcome-order.js +171 -0
- package/dist/operations/scale.d.ts +3 -0
- package/dist/operations/scale.d.ts.map +1 -0
- package/dist/operations/scale.js +212 -0
- package/dist/operations/set-tpsl.d.ts +3 -0
- package/dist/operations/set-tpsl.d.ts.map +1 -0
- package/dist/operations/set-tpsl.js +277 -0
- package/dist/operations/spot-order.d.ts +3 -0
- package/dist/operations/spot-order.d.ts.map +1 -0
- package/dist/operations/spot-order.js +173 -0
- package/dist/operations/trigger-order.d.ts +3 -0
- package/dist/operations/trigger-order.d.ts.map +1 -0
- package/dist/operations/trigger-order.js +177 -0
- package/dist/operations/twap-cancel.d.ts +3 -0
- package/dist/operations/twap-cancel.d.ts.map +1 -0
- package/dist/operations/twap-cancel.js +57 -0
- package/dist/operations/twap-status.d.ts +3 -0
- package/dist/operations/twap-status.d.ts.map +1 -0
- package/dist/operations/twap-status.js +81 -0
- package/dist/operations/twap.d.ts +3 -0
- package/dist/operations/twap.d.ts.map +1 -0
- package/dist/operations/twap.js +124 -0
- package/dist/setup/approve-builder.d.ts +3 -0
- package/dist/setup/approve-builder.d.ts.map +1 -0
- package/dist/setup/approve-builder.js +155 -0
- package/dist/setup/env.d.ts +4 -0
- package/dist/setup/env.d.ts.map +1 -0
- package/dist/setup/env.js +8 -0
- package/dist/setup/onboard.d.ts +10 -0
- package/dist/setup/onboard.d.ts.map +1 -0
- package/dist/setup/onboard.js +462 -0
- package/package.json +10 -4
|
@@ -0,0 +1,2040 @@
|
|
|
1
|
+
// Hyperliquid Client for Open Broker
|
|
2
|
+
// Uses @nktkas/hyperliquid SDK
|
|
3
|
+
import { HttpTransport, InfoClient, ExchangeClient } from '@nktkas/hyperliquid';
|
|
4
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
5
|
+
import { loadConfig, isMainnet } from './config.js';
|
|
6
|
+
import { roundPrice, roundSize } from './utils.js';
|
|
7
|
+
export class HyperliquidClient {
|
|
8
|
+
config;
|
|
9
|
+
account;
|
|
10
|
+
transport;
|
|
11
|
+
info;
|
|
12
|
+
exchange;
|
|
13
|
+
meta = null;
|
|
14
|
+
assetMap = new Map();
|
|
15
|
+
szDecimalsMap = new Map();
|
|
16
|
+
/** Maps coin name → dex info for HIP-3 assets. Main dex assets have dexName=null */
|
|
17
|
+
coinDexMap = new Map();
|
|
18
|
+
/** Cache of perpDexs list */
|
|
19
|
+
perpDexsCache = null;
|
|
20
|
+
/** Whether HIP-3 assets have been loaded into maps */
|
|
21
|
+
hip3Loaded = false;
|
|
22
|
+
/** Whether API wallet setup has been validated */
|
|
23
|
+
apiWalletValidated = false;
|
|
24
|
+
/** Set of HIP-3 dex names that have been loaded (for testnet on-demand loading) */
|
|
25
|
+
loadedHip3Dexes = new Set();
|
|
26
|
+
/** HIP-3 assets that have had isolated margin set this session */
|
|
27
|
+
hip3IsolatedSet = new Set();
|
|
28
|
+
/** Cached maxLeverage for HIP-3 assets */
|
|
29
|
+
hip3MaxLeverageMap = new Map();
|
|
30
|
+
/** Cached account abstraction mode: 'standard' | 'unified' | 'portfolio' | 'dexAbstraction' */
|
|
31
|
+
accountMode = null;
|
|
32
|
+
/** Spot asset index map: coin name → 10000 + spotMeta.universe[i].index */
|
|
33
|
+
spotAssetMap = new Map();
|
|
34
|
+
/** Spot market key map: coin name → pair.name (e.g. "@230", "PURR/USDC") */
|
|
35
|
+
spotPairNameMap = new Map();
|
|
36
|
+
/** Spot szDecimals map: coin name → base token szDecimals */
|
|
37
|
+
spotSzDecimalsMap = new Map();
|
|
38
|
+
/** Whether spot metadata has been loaded */
|
|
39
|
+
spotMetaLoaded = false;
|
|
40
|
+
/** HIP-4 outcome metadata cache */
|
|
41
|
+
outcomeMeta = null;
|
|
42
|
+
verbose = false;
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = config ?? loadConfig();
|
|
45
|
+
this.account = privateKeyToAccount(this.config.privateKey);
|
|
46
|
+
this.verbose = this.config.verbose;
|
|
47
|
+
// Initialize SDK clients
|
|
48
|
+
this.transport = new HttpTransport({ isTestnet: !isMainnet() });
|
|
49
|
+
this.info = new InfoClient({ transport: this.transport });
|
|
50
|
+
this.exchange = new ExchangeClient({
|
|
51
|
+
transport: this.transport,
|
|
52
|
+
wallet: this.account,
|
|
53
|
+
});
|
|
54
|
+
this.log('Client init:', JSON.stringify({
|
|
55
|
+
network: isMainnet() ? 'mainnet' : 'testnet',
|
|
56
|
+
apiUrl: this.config.baseUrl,
|
|
57
|
+
accountAddress: this.config.accountAddress,
|
|
58
|
+
walletAddress: this.config.walletAddress,
|
|
59
|
+
isApiWallet: this.config.isApiWallet,
|
|
60
|
+
isReadOnly: this.config.isReadOnly,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
log(...args) {
|
|
64
|
+
if (this.verbose) {
|
|
65
|
+
console.log('[DEBUG]', ...args);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
describeError(error) {
|
|
69
|
+
if (!(error instanceof Error))
|
|
70
|
+
return String(error);
|
|
71
|
+
const response = error.response;
|
|
72
|
+
const body = error.body;
|
|
73
|
+
const cause = error.cause;
|
|
74
|
+
const parts = [error.message];
|
|
75
|
+
if (response) {
|
|
76
|
+
parts.push(`status=${response.status} ${response.statusText}`.trim());
|
|
77
|
+
}
|
|
78
|
+
if (body) {
|
|
79
|
+
parts.push(`body=${body.length > 300 ? `${body.slice(0, 300)}...` : body}`);
|
|
80
|
+
}
|
|
81
|
+
if (cause instanceof Error && cause.message && cause.message !== error.message) {
|
|
82
|
+
parts.push(`cause=${cause.message}`);
|
|
83
|
+
}
|
|
84
|
+
else if (cause && !(cause instanceof Error)) {
|
|
85
|
+
parts.push(`cause=${String(cause)}`);
|
|
86
|
+
}
|
|
87
|
+
return parts.join(' | ');
|
|
88
|
+
}
|
|
89
|
+
/** Retry an async operation on transient failures (fetch failed, ECONNRESET, etc.) */
|
|
90
|
+
async withRetry(fn, label, maxRetries = 3) {
|
|
91
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
92
|
+
try {
|
|
93
|
+
return await fn();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
97
|
+
const isTransient = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|socket hang up/i.test(message);
|
|
98
|
+
if (!isTransient || attempt === maxRetries)
|
|
99
|
+
throw error;
|
|
100
|
+
const delay = attempt * 1000; // 1s, 2s, 3s
|
|
101
|
+
this.log(`${label} attempt ${attempt}/${maxRetries} failed (${message}), retrying in ${delay}ms...`);
|
|
102
|
+
await new Promise(r => setTimeout(r, delay));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw new Error('unreachable');
|
|
106
|
+
}
|
|
107
|
+
getTransportContext(label) {
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
label,
|
|
110
|
+
network: isMainnet() ? 'mainnet' : 'testnet',
|
|
111
|
+
apiUrl: this.config.baseUrl,
|
|
112
|
+
accountAddress: this.config.accountAddress,
|
|
113
|
+
walletAddress: this.config.walletAddress,
|
|
114
|
+
isApiWallet: this.config.isApiWallet,
|
|
115
|
+
isReadOnly: this.config.isReadOnly,
|
|
116
|
+
verbose: this.verbose,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async postInfo(payload, label) {
|
|
120
|
+
const baseUrl = isMainnet()
|
|
121
|
+
? 'https://api.hyperliquid.xyz'
|
|
122
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
123
|
+
const response = await this.withRetry(async () => {
|
|
124
|
+
try {
|
|
125
|
+
return await fetch(baseUrl + '/info', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
body: JSON.stringify(payload),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
throw new Error(`${label} request failed before response: ${message}`);
|
|
134
|
+
}
|
|
135
|
+
}, label);
|
|
136
|
+
const text = await response.text();
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const snippet = text.length > 300 ? `${text.slice(0, 300)}...` : text;
|
|
139
|
+
throw new Error(`${label} failed: HTTP ${response.status} ${response.statusText}${snippet ? ` | body=${snippet}` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
146
|
+
throw new Error(`${label} returned invalid JSON: ${message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The address we're trading on behalf of (may be different from wallet if using API wallet) */
|
|
150
|
+
get address() {
|
|
151
|
+
return this.config.accountAddress;
|
|
152
|
+
}
|
|
153
|
+
/** The address of the signing wallet (derived from private key) */
|
|
154
|
+
get walletAddress() {
|
|
155
|
+
return this.config.walletAddress;
|
|
156
|
+
}
|
|
157
|
+
/** Whether we're using an API wallet (signing wallet differs from trading account) */
|
|
158
|
+
get isApiWallet() {
|
|
159
|
+
return this.config.isApiWallet;
|
|
160
|
+
}
|
|
161
|
+
get builderInfo() {
|
|
162
|
+
return {
|
|
163
|
+
b: this.config.builderAddress.toLowerCase(),
|
|
164
|
+
f: this.config.builderFee,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
get builderAddress() {
|
|
168
|
+
return this.config.builderAddress;
|
|
169
|
+
}
|
|
170
|
+
get builderFeeBps() {
|
|
171
|
+
return this.config.builderFee / 10; // Convert from tenths of bps to bps
|
|
172
|
+
}
|
|
173
|
+
/** Whether client is in read-only mode (no trading capability) */
|
|
174
|
+
get isReadOnly() {
|
|
175
|
+
return this.config.isReadOnly;
|
|
176
|
+
}
|
|
177
|
+
/** Whether connected to testnet (HIP-3 dexes not auto-loaded) */
|
|
178
|
+
get isTestnet() {
|
|
179
|
+
return !isMainnet();
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Returns vaultAddress param for SDK exchange calls.
|
|
183
|
+
* Only used for vault trading (HYPERLIQUID_VAULT_ADDRESS set explicitly).
|
|
184
|
+
* Standard API wallets (agents) do NOT need this — the API maps agent → master automatically.
|
|
185
|
+
*/
|
|
186
|
+
get vaultParam() {
|
|
187
|
+
if (this.config.vaultAddress) {
|
|
188
|
+
return { vaultAddress: this.config.vaultAddress };
|
|
189
|
+
}
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
/** Throw error if trying to trade in read-only mode. Validates API wallet on first call. */
|
|
193
|
+
async requireTrading() {
|
|
194
|
+
if (this.config.isReadOnly) {
|
|
195
|
+
throw new Error('Trading not available. Run "openbroker setup" to configure your wallet.');
|
|
196
|
+
}
|
|
197
|
+
// One-time API wallet validation on first trade attempt
|
|
198
|
+
if (this.config.isApiWallet && !this.apiWalletValidated) {
|
|
199
|
+
this.apiWalletValidated = true;
|
|
200
|
+
await this.validateApiWalletSetup();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ============ Market Data ============
|
|
204
|
+
async getMetaAndAssetCtxs() {
|
|
205
|
+
if (this.meta)
|
|
206
|
+
return this.meta;
|
|
207
|
+
this.log('Fetching metaAndAssetCtxs...');
|
|
208
|
+
let response;
|
|
209
|
+
try {
|
|
210
|
+
response = await this.withRetry(() => this.info.metaAndAssetCtxs(), 'metaAndAssetCtxs');
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.log('metaAndAssetCtxs failure context:', this.getTransportContext('metaAndAssetCtxs'));
|
|
214
|
+
if (error instanceof Error && error.stack) {
|
|
215
|
+
this.log('metaAndAssetCtxs stack:', error.stack);
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`metaAndAssetCtxs failed: ${this.describeError(error)}`);
|
|
218
|
+
}
|
|
219
|
+
this.log('metaAndAssetCtxs response:', JSON.stringify(response, null, 2).slice(0, 500) + '...');
|
|
220
|
+
// Build the narrowed value locally so neither narrowing nor the `| null`
|
|
221
|
+
// field type can be lost across `await` / method-call boundaries below.
|
|
222
|
+
const meta = {
|
|
223
|
+
meta: { universe: response[0].universe },
|
|
224
|
+
assetCtxs: response[1],
|
|
225
|
+
};
|
|
226
|
+
this.meta = meta;
|
|
227
|
+
// Build lookup maps for main dex
|
|
228
|
+
meta.meta.universe.forEach((asset, index) => {
|
|
229
|
+
this.assetMap.set(asset.name, index);
|
|
230
|
+
this.szDecimalsMap.set(asset.name, asset.szDecimals);
|
|
231
|
+
this.coinDexMap.set(asset.name, { dexName: null, dexIdx: 0, localName: asset.name });
|
|
232
|
+
});
|
|
233
|
+
// Load HIP-3 dex assets (only once - maps persist across meta cache invalidation)
|
|
234
|
+
if (!this.hip3Loaded) {
|
|
235
|
+
await this.loadHip3Assets();
|
|
236
|
+
this.hip3Loaded = true;
|
|
237
|
+
}
|
|
238
|
+
return meta;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Load HIP-3 perp dex assets into the asset/szDecimals maps.
|
|
242
|
+
* Asset index formula: 100000 + dexIdx * 10000 + assetIdx
|
|
243
|
+
* Coins are keyed as "dexName:COIN" (e.g., "xyz:CL")
|
|
244
|
+
*/
|
|
245
|
+
/** Max concurrent HIP-3 API requests to avoid rate limiting */
|
|
246
|
+
static HIP3_CONCURRENCY = 5;
|
|
247
|
+
/**
|
|
248
|
+
* Like Promise.allSettled but with a concurrency limit.
|
|
249
|
+
* Processes tasks in batches to avoid hitting API rate limits.
|
|
250
|
+
*/
|
|
251
|
+
async batchSettled(tasks) {
|
|
252
|
+
const results = [];
|
|
253
|
+
const concurrency = HyperliquidClient.HIP3_CONCURRENCY;
|
|
254
|
+
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
255
|
+
const batch = tasks.slice(i, i + concurrency);
|
|
256
|
+
const batchResults = await Promise.allSettled(batch.map(fn => fn()));
|
|
257
|
+
results.push(...batchResults);
|
|
258
|
+
}
|
|
259
|
+
return results;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Load HIP-3 perp dex assets into the asset/szDecimals maps.
|
|
263
|
+
* On testnet: skips auto-loading (too many junk dexes). Use loadSingleHip3Dex() on demand.
|
|
264
|
+
* On mainnet: loads all dexes with concurrency limit.
|
|
265
|
+
*/
|
|
266
|
+
async loadHip3Assets() {
|
|
267
|
+
try {
|
|
268
|
+
const dexs = await this.getPerpDexs();
|
|
269
|
+
// On testnet, skip auto-loading — too many junk dexes cause rate limiting.
|
|
270
|
+
// Users can reference specific dexes (e.g., "felix:BTC") which triggers on-demand loading.
|
|
271
|
+
if (!isMainnet()) {
|
|
272
|
+
const dexCount = dexs.filter(d => d != null).length - 1; // exclude null at index 0
|
|
273
|
+
if (dexCount > 0) {
|
|
274
|
+
this.log(`Testnet: skipping auto-load of ${dexCount} HIP-3 dexes. Use "dexName:COIN" to load a specific dex on demand.`);
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Mainnet: load all dexes
|
|
279
|
+
const dexEntries = [];
|
|
280
|
+
for (let dexIdx = 1; dexIdx < dexs.length; dexIdx++) {
|
|
281
|
+
const dex = dexs[dexIdx];
|
|
282
|
+
if (dex)
|
|
283
|
+
dexEntries.push({ dex, dexIdx });
|
|
284
|
+
}
|
|
285
|
+
const results = await this.batchSettled(dexEntries.map(({ dex, dexIdx }) => async () => {
|
|
286
|
+
const data = await this.postInfo({ type: 'metaAndAssetCtxs', dex: dex.name }, `metaAndAssetCtxs(${dex.name})`);
|
|
287
|
+
return { dex, dexIdx, data };
|
|
288
|
+
}));
|
|
289
|
+
for (const result of results) {
|
|
290
|
+
if (result.status === 'rejected') {
|
|
291
|
+
this.log(`Failed to load HIP-3 dex:`, result.reason);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const { dex, dexIdx, data: dexData } = result.value;
|
|
295
|
+
this.registerHip3Dex(dex.name, dexIdx, dexData);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
this.log('Failed to load HIP-3 assets:', e);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Load a single HIP-3 dex by name (on-demand, e.g. when user references "felix:BTC").
|
|
304
|
+
* No-op if already loaded.
|
|
305
|
+
*/
|
|
306
|
+
async loadSingleHip3Dex(dexName) {
|
|
307
|
+
if (this.loadedHip3Dexes.has(dexName))
|
|
308
|
+
return true;
|
|
309
|
+
const dexs = await this.getPerpDexs();
|
|
310
|
+
const dexIdx = dexs.findIndex(d => d?.name === dexName);
|
|
311
|
+
if (dexIdx < 1) {
|
|
312
|
+
this.log(`HIP-3 dex "${dexName}" not found in perpDexs list`);
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
this.log(`On-demand loading HIP-3 dex: ${dexName}`);
|
|
317
|
+
const data = await this.postInfo({ type: 'metaAndAssetCtxs', dex: dexName }, `metaAndAssetCtxs(${dexName})`);
|
|
318
|
+
this.registerHip3Dex(dexName, dexIdx, data);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
this.log(`Failed to load HIP-3 dex ${dexName}:`, e);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get HIP-3 dexes to iterate over for bulk queries.
|
|
328
|
+
* Mainnet: all dexes. Testnet: only explicitly loaded dexes.
|
|
329
|
+
*/
|
|
330
|
+
async getIterableHip3Dexs() {
|
|
331
|
+
const dexs = await this.getPerpDexs();
|
|
332
|
+
const all = dexs.slice(1).filter((d) => d != null);
|
|
333
|
+
if (isMainnet())
|
|
334
|
+
return all;
|
|
335
|
+
// Testnet: only return dexes that have been explicitly loaded
|
|
336
|
+
return all.filter(d => this.loadedHip3Dexes.has(d.name));
|
|
337
|
+
}
|
|
338
|
+
/** Register a fetched HIP-3 dex's assets into lookup maps */
|
|
339
|
+
registerHip3Dex(dexName, dexIdx, dexData) {
|
|
340
|
+
if (dexData && dexData[0]?.universe) {
|
|
341
|
+
const universe = dexData[0].universe;
|
|
342
|
+
this.log(`Loading HIP-3 dex: ${dexName} with ${universe.length} markets`);
|
|
343
|
+
universe.forEach((asset, assetIdx) => {
|
|
344
|
+
const coinName = asset.name;
|
|
345
|
+
const localName = coinName.startsWith(dexName + ':') ? coinName.slice(dexName.length + 1) : coinName;
|
|
346
|
+
const globalIndex = 100000 + dexIdx * 10000 + assetIdx;
|
|
347
|
+
this.assetMap.set(coinName, globalIndex);
|
|
348
|
+
this.szDecimalsMap.set(coinName, asset.szDecimals);
|
|
349
|
+
this.coinDexMap.set(coinName, { dexName, dexIdx, localName });
|
|
350
|
+
if (asset.maxLeverage)
|
|
351
|
+
this.hip3MaxLeverageMap.set(coinName, asset.maxLeverage);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
this.loadedHip3Dexes.add(dexName);
|
|
355
|
+
}
|
|
356
|
+
async getAllMids() {
|
|
357
|
+
this.log('Fetching allMids...');
|
|
358
|
+
let response;
|
|
359
|
+
try {
|
|
360
|
+
response = await this.withRetry(() => this.info.allMids(), 'allMids');
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
this.log('allMids failure context:', this.getTransportContext('allMids'));
|
|
364
|
+
if (error instanceof Error && error.stack) {
|
|
365
|
+
this.log('allMids stack:', error.stack);
|
|
366
|
+
}
|
|
367
|
+
throw new Error(`allMids failed: ${this.describeError(error)}`);
|
|
368
|
+
}
|
|
369
|
+
// Also fetch HIP-3 dex mids (in parallel; testnet: only loaded dexes)
|
|
370
|
+
try {
|
|
371
|
+
const validDexs = await this.getIterableHip3Dexs();
|
|
372
|
+
const results = await this.batchSettled(validDexs.map((dex) => async () => {
|
|
373
|
+
const mids = await this.postInfo({ type: 'allMids', dex: dex.name }, `allMids(${dex.name})`);
|
|
374
|
+
return { dex, mids };
|
|
375
|
+
}));
|
|
376
|
+
for (const result of results) {
|
|
377
|
+
if (result.status === 'rejected') {
|
|
378
|
+
this.log('Failed to fetch HIP-3 dex mids:', result.reason);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
for (const [coin, mid] of Object.entries(result.value.mids)) {
|
|
382
|
+
response[coin] = mid;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
this.log('Failed to fetch HIP-3 mids:', e);
|
|
388
|
+
}
|
|
389
|
+
return response;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Get all perpetual DEXs (including HIP-3 builder-deployed markets)
|
|
393
|
+
* Returns array where index 0 is null (main dex), others are HIP-3 dexs
|
|
394
|
+
*/
|
|
395
|
+
async getPerpDexs() {
|
|
396
|
+
if (this.perpDexsCache)
|
|
397
|
+
return this.perpDexsCache;
|
|
398
|
+
this.log('Fetching perpDexs...');
|
|
399
|
+
const data = await this.postInfo({ type: 'perpDexs' }, 'perpDexs');
|
|
400
|
+
this.log('perpDexs response:', JSON.stringify(data).slice(0, 500));
|
|
401
|
+
this.perpDexsCache = data;
|
|
402
|
+
return data;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Get all perp markets including HIP-3 dexs
|
|
406
|
+
* Returns array of [meta, assetCtxs] for each dex
|
|
407
|
+
*/
|
|
408
|
+
async getAllPerpMetas() {
|
|
409
|
+
this.log('Fetching all perp markets...');
|
|
410
|
+
const results = [];
|
|
411
|
+
// Get main dex data (no dex parameter)
|
|
412
|
+
const mainData = await this.postInfo({ type: 'metaAndAssetCtxs' }, 'metaAndAssetCtxs(main)');
|
|
413
|
+
this.log('Main dex data fetched');
|
|
414
|
+
results.push({
|
|
415
|
+
dexName: null,
|
|
416
|
+
meta: { universe: mainData[0].universe },
|
|
417
|
+
assetCtxs: mainData[1],
|
|
418
|
+
});
|
|
419
|
+
// Get HIP-3 dex names and fetch all in parallel (testnet: only loaded dexes)
|
|
420
|
+
const validDexs = await this.getIterableHip3Dexs();
|
|
421
|
+
const hip3Results = await this.batchSettled(validDexs.map((dex) => async () => {
|
|
422
|
+
const data = await this.postInfo({ type: 'metaAndAssetCtxs', dex: dex.name }, `metaAndAssetCtxs(${dex.name})`);
|
|
423
|
+
return { dex, data };
|
|
424
|
+
}));
|
|
425
|
+
for (const result of hip3Results) {
|
|
426
|
+
if (result.status === 'rejected') {
|
|
427
|
+
this.log('Failed to fetch HIP-3 dex:', result.reason);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const { dex, data: dexData } = result.value;
|
|
431
|
+
if (dexData && dexData[0]?.universe) {
|
|
432
|
+
this.log(`Fetched HIP-3 dex: ${dex.name} with ${dexData[0].universe.length} markets`);
|
|
433
|
+
results.push({
|
|
434
|
+
dexName: dex.name,
|
|
435
|
+
meta: { universe: dexData[0].universe },
|
|
436
|
+
assetCtxs: dexData[1] || [],
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return results;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Get spot market metadata
|
|
444
|
+
*/
|
|
445
|
+
async getSpotMeta() {
|
|
446
|
+
this.log('Fetching spotMeta...');
|
|
447
|
+
const data = await this.postInfo({ type: 'spotMeta' }, 'spotMeta');
|
|
448
|
+
this.log('spotMeta response:', JSON.stringify(data).slice(0, 500));
|
|
449
|
+
return data;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Get spot metadata with asset contexts (prices, volumes)
|
|
453
|
+
*/
|
|
454
|
+
async getSpotMetaAndAssetCtxs() {
|
|
455
|
+
this.log('Fetching spotMetaAndAssetCtxs...');
|
|
456
|
+
const data = await this.postInfo({ type: 'spotMetaAndAssetCtxs' }, 'spotMetaAndAssetCtxs');
|
|
457
|
+
this.log('spotMetaAndAssetCtxs response:', JSON.stringify(data).slice(0, 500));
|
|
458
|
+
if (!Array.isArray(data) || !data[0] || !data[1]) {
|
|
459
|
+
this.log('spotMetaAndAssetCtxs returned null/malformed data, falling back to spotMeta + allMids');
|
|
460
|
+
const [meta, mids] = await Promise.all([
|
|
461
|
+
this.getSpotMeta(),
|
|
462
|
+
this.getAllMids(),
|
|
463
|
+
]);
|
|
464
|
+
return {
|
|
465
|
+
meta,
|
|
466
|
+
assetCtxs: meta.universe.map((pair) => {
|
|
467
|
+
const price = mids[pair.name] ?? '0';
|
|
468
|
+
return {
|
|
469
|
+
coin: pair.name,
|
|
470
|
+
dayNtlVlm: '0',
|
|
471
|
+
markPx: price,
|
|
472
|
+
midPx: price,
|
|
473
|
+
prevDayPx: price,
|
|
474
|
+
};
|
|
475
|
+
}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
meta: data[0],
|
|
480
|
+
assetCtxs: data[1],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// ============ HIP-4 Outcomes ============
|
|
484
|
+
parseOutcomeDescription(description) {
|
|
485
|
+
const parsed = {};
|
|
486
|
+
for (const part of description.split('|')) {
|
|
487
|
+
const rawSegment = part.trim();
|
|
488
|
+
const metadataIdx = rawSegment.indexOf('metadata=');
|
|
489
|
+
const segments = metadataIdx >= 0
|
|
490
|
+
? [rawSegment, rawSegment.slice(metadataIdx + 'metadata='.length)]
|
|
491
|
+
: [rawSegment];
|
|
492
|
+
for (const segment of segments) {
|
|
493
|
+
const idx = segment.indexOf(':');
|
|
494
|
+
if (idx <= 0)
|
|
495
|
+
continue;
|
|
496
|
+
const key = segment.slice(0, idx).trim();
|
|
497
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(key))
|
|
498
|
+
continue;
|
|
499
|
+
parsed[key] = segment.slice(idx + 1).trim();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return parsed;
|
|
503
|
+
}
|
|
504
|
+
normalizeOutcomeSide(side) {
|
|
505
|
+
if (typeof side === 'number') {
|
|
506
|
+
if (side === 0 || side === 1)
|
|
507
|
+
return side;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
const normalized = side.trim().toLowerCase();
|
|
511
|
+
if (normalized === '0' || normalized === 'yes' || normalized === 'y')
|
|
512
|
+
return 0;
|
|
513
|
+
if (normalized === '1' || normalized === 'no' || normalized === 'n')
|
|
514
|
+
return 1;
|
|
515
|
+
}
|
|
516
|
+
throw new Error(`Invalid outcome side "${side}". Use yes/no or 0/1.`);
|
|
517
|
+
}
|
|
518
|
+
getOutcomeEncoding(outcome, side) {
|
|
519
|
+
return 10 * outcome + side;
|
|
520
|
+
}
|
|
521
|
+
getOutcomeCoin(outcome, side) {
|
|
522
|
+
return `#${this.getOutcomeEncoding(outcome, side)}`;
|
|
523
|
+
}
|
|
524
|
+
getOutcomeAssetId(outcome, side) {
|
|
525
|
+
return 100_000_000 + this.getOutcomeEncoding(outcome, side);
|
|
526
|
+
}
|
|
527
|
+
resolveOutcomeRef(ref, side) {
|
|
528
|
+
if (typeof ref === 'number') {
|
|
529
|
+
const resolvedSide = this.normalizeOutcomeSide(side ?? 0);
|
|
530
|
+
const encoding = this.getOutcomeEncoding(ref, resolvedSide);
|
|
531
|
+
return {
|
|
532
|
+
outcome: ref,
|
|
533
|
+
side: resolvedSide,
|
|
534
|
+
encoding,
|
|
535
|
+
coin: `#${encoding}`,
|
|
536
|
+
tokenName: `+${encoding}`,
|
|
537
|
+
assetId: 100_000_000 + encoding,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const trimmed = ref.trim();
|
|
541
|
+
const encoded = trimmed.startsWith('#') || trimmed.startsWith('+')
|
|
542
|
+
? parseInt(trimmed.slice(1), 10)
|
|
543
|
+
: NaN;
|
|
544
|
+
if (!Number.isNaN(encoded)) {
|
|
545
|
+
const resolvedSide = encoded % 10;
|
|
546
|
+
if (resolvedSide !== 0 && resolvedSide !== 1) {
|
|
547
|
+
throw new Error(`Invalid outcome encoding "${ref}". Outcome side must encode to 0 or 1.`);
|
|
548
|
+
}
|
|
549
|
+
const outcome = Math.floor(encoded / 10);
|
|
550
|
+
return {
|
|
551
|
+
outcome,
|
|
552
|
+
side: resolvedSide,
|
|
553
|
+
encoding: encoded,
|
|
554
|
+
coin: `#${encoded}`,
|
|
555
|
+
tokenName: `+${encoded}`,
|
|
556
|
+
assetId: 100_000_000 + encoded,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const outcome = parseInt(trimmed, 10);
|
|
560
|
+
if (!Number.isFinite(outcome) || outcome < 0) {
|
|
561
|
+
throw new Error(`Invalid outcome reference "${ref}". Use an outcome id, #<encoding>, or +<encoding>.`);
|
|
562
|
+
}
|
|
563
|
+
const resolvedSide = this.normalizeOutcomeSide(side ?? 0);
|
|
564
|
+
const encoding = this.getOutcomeEncoding(outcome, resolvedSide);
|
|
565
|
+
return {
|
|
566
|
+
outcome,
|
|
567
|
+
side: resolvedSide,
|
|
568
|
+
encoding,
|
|
569
|
+
coin: `#${encoding}`,
|
|
570
|
+
tokenName: `+${encoding}`,
|
|
571
|
+
assetId: 100_000_000 + encoding,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async getOutcomeMeta() {
|
|
575
|
+
if (this.outcomeMeta)
|
|
576
|
+
return this.outcomeMeta;
|
|
577
|
+
this.log('Fetching outcomeMeta...');
|
|
578
|
+
const data = await this.postInfo({ type: 'outcomeMeta' }, 'outcomeMeta');
|
|
579
|
+
if (!data || !Array.isArray(data.outcomes)) {
|
|
580
|
+
throw new Error('outcomeMeta returned empty/malformed payload.');
|
|
581
|
+
}
|
|
582
|
+
this.outcomeMeta = {
|
|
583
|
+
outcomes: data.outcomes,
|
|
584
|
+
questions: data.questions ?? [],
|
|
585
|
+
};
|
|
586
|
+
this.log(`Loaded ${this.outcomeMeta.outcomes.length} outcome markets`);
|
|
587
|
+
return this.outcomeMeta;
|
|
588
|
+
}
|
|
589
|
+
async getOutcomeCtxMap() {
|
|
590
|
+
const ctxMap = new Map();
|
|
591
|
+
try {
|
|
592
|
+
const spotData = await this.getSpotMetaAndAssetCtxs();
|
|
593
|
+
for (const ctx of spotData.assetCtxs) {
|
|
594
|
+
if (ctx.coin)
|
|
595
|
+
ctxMap.set(ctx.coin, ctx);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
this.log('Unable to load spot/outcome contexts:', e);
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const mids = await this.getAllMids();
|
|
603
|
+
for (const [coin, midPx] of Object.entries(mids)) {
|
|
604
|
+
if (!coin.startsWith('#'))
|
|
605
|
+
continue;
|
|
606
|
+
const existing = ctxMap.get(coin) ?? { coin };
|
|
607
|
+
existing.midPx = existing.midPx ?? midPx;
|
|
608
|
+
existing.markPx = existing.markPx ?? midPx;
|
|
609
|
+
ctxMap.set(coin, existing);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
this.log('Unable to load allMids for outcomes:', e);
|
|
614
|
+
}
|
|
615
|
+
return ctxMap;
|
|
616
|
+
}
|
|
617
|
+
async getOutcomeMarkets() {
|
|
618
|
+
const [meta, spotMeta, ctxMap] = await Promise.all([
|
|
619
|
+
this.getOutcomeMeta(),
|
|
620
|
+
this.getSpotMeta().catch(() => null),
|
|
621
|
+
this.getOutcomeCtxMap(),
|
|
622
|
+
]);
|
|
623
|
+
const tokenDecimals = new Map();
|
|
624
|
+
if (spotMeta) {
|
|
625
|
+
for (const token of spotMeta.tokens) {
|
|
626
|
+
tokenDecimals.set(token.index, token.szDecimals);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const questions = new Map();
|
|
630
|
+
for (const question of meta.questions ?? []) {
|
|
631
|
+
for (const outcome of question.namedOutcomes) {
|
|
632
|
+
questions.set(outcome, question);
|
|
633
|
+
}
|
|
634
|
+
questions.set(question.fallbackOutcome, question);
|
|
635
|
+
}
|
|
636
|
+
return meta.outcomes.map((outcome) => {
|
|
637
|
+
const sides = outcome.sideSpecs.map((sideSpec, idx) => {
|
|
638
|
+
const side = idx;
|
|
639
|
+
const encoding = this.getOutcomeEncoding(outcome.outcome, side);
|
|
640
|
+
const coin = `#${encoding}`;
|
|
641
|
+
const ctx = ctxMap.get(coin);
|
|
642
|
+
return {
|
|
643
|
+
side,
|
|
644
|
+
name: sideSpec.name,
|
|
645
|
+
encoding,
|
|
646
|
+
coin,
|
|
647
|
+
tokenName: `+${encoding}`,
|
|
648
|
+
assetId: 100_000_000 + encoding,
|
|
649
|
+
token: sideSpec.token,
|
|
650
|
+
szDecimals: sideSpec.token !== undefined ? tokenDecimals.get(sideSpec.token) : undefined,
|
|
651
|
+
midPx: ctx?.midPx ?? undefined,
|
|
652
|
+
markPx: ctx?.markPx,
|
|
653
|
+
prevDayPx: ctx?.prevDayPx,
|
|
654
|
+
dayNtlVlm: ctx?.dayNtlVlm,
|
|
655
|
+
};
|
|
656
|
+
});
|
|
657
|
+
return {
|
|
658
|
+
outcome: outcome.outcome,
|
|
659
|
+
name: outcome.name,
|
|
660
|
+
description: outcome.description,
|
|
661
|
+
parsedDescription: this.parseOutcomeDescription(outcome.description),
|
|
662
|
+
sides,
|
|
663
|
+
question: questions.get(outcome.outcome),
|
|
664
|
+
};
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
async getOutcomeMarket(outcomeId) {
|
|
668
|
+
const markets = await this.getOutcomeMarkets();
|
|
669
|
+
return markets.find((market) => market.outcome === outcomeId) ?? null;
|
|
670
|
+
}
|
|
671
|
+
async getOutcomeSzDecimals(outcome, side) {
|
|
672
|
+
const market = await this.getOutcomeMarket(outcome);
|
|
673
|
+
return market?.sides.find((s) => s.side === side)?.szDecimals ?? 0;
|
|
674
|
+
}
|
|
675
|
+
async getOutcomeMidPrice(outcome, side) {
|
|
676
|
+
const coin = this.getOutcomeCoin(outcome, side);
|
|
677
|
+
const markets = await this.getOutcomeMarkets();
|
|
678
|
+
const marketSide = markets
|
|
679
|
+
.find((market) => market.outcome === outcome)
|
|
680
|
+
?.sides.find((s) => s.side === side);
|
|
681
|
+
const fromMeta = marketSide?.midPx ?? marketSide?.markPx;
|
|
682
|
+
if (fromMeta) {
|
|
683
|
+
const mid = parseFloat(fromMeta);
|
|
684
|
+
if (mid > 0)
|
|
685
|
+
return mid;
|
|
686
|
+
}
|
|
687
|
+
const mids = await this.getAllMids();
|
|
688
|
+
const mid = parseFloat(mids[coin] || '0');
|
|
689
|
+
if (mid > 0)
|
|
690
|
+
return mid;
|
|
691
|
+
try {
|
|
692
|
+
const book = await this.getL2Book(coin);
|
|
693
|
+
if (book.midPrice > 0)
|
|
694
|
+
return book.midPrice;
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
this.log(`Unable to fetch outcome L2 book for ${coin}:`, e);
|
|
698
|
+
}
|
|
699
|
+
throw new Error(`No outcome price for ${coin}. The market may not be open or may have no liquidity.`);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Load spot metadata into lookup maps.
|
|
703
|
+
* Spot asset index for orders = 10000 + universe[i].index
|
|
704
|
+
* Uses the base token's szDecimals for size rounding.
|
|
705
|
+
*/
|
|
706
|
+
async loadSpotMeta() {
|
|
707
|
+
if (this.spotMetaLoaded)
|
|
708
|
+
return;
|
|
709
|
+
try {
|
|
710
|
+
const spotData = await this.getSpotMeta();
|
|
711
|
+
// Build token lookup for szDecimals
|
|
712
|
+
const tokenMap = new Map();
|
|
713
|
+
for (const token of spotData.tokens) {
|
|
714
|
+
tokenMap.set(token.index, { name: token.name, szDecimals: token.szDecimals });
|
|
715
|
+
}
|
|
716
|
+
for (const pair of spotData.universe) {
|
|
717
|
+
// pair.name is the market name (e.g., "PURR/USDC", "@107")
|
|
718
|
+
// pair.tokens = [baseTokenIndex, quoteTokenIndex]
|
|
719
|
+
// pair.index is the spot universe index
|
|
720
|
+
const baseToken = tokenMap.get(pair.tokens[0]);
|
|
721
|
+
if (!baseToken)
|
|
722
|
+
continue;
|
|
723
|
+
const spotAssetIndex = 10000 + pair.index;
|
|
724
|
+
const quoteTokenIdx = pair.tokens[1];
|
|
725
|
+
// A token can appear in multiple pairs (e.g., HYPE/USDC, HYPE/USDE, HYPE/USDH).
|
|
726
|
+
// Prefer the USDC pair (quote token index 0) for the primary mapping.
|
|
727
|
+
const existing = this.spotAssetMap.get(baseToken.name);
|
|
728
|
+
if (existing !== undefined && quoteTokenIdx !== 0) {
|
|
729
|
+
// Already have a mapping — skip non-USDC pairs
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
this.spotAssetMap.set(baseToken.name, spotAssetIndex);
|
|
733
|
+
this.spotPairNameMap.set(baseToken.name, pair.name);
|
|
734
|
+
this.spotSzDecimalsMap.set(baseToken.name, baseToken.szDecimals);
|
|
735
|
+
this.log(`Spot: ${baseToken.name} → asset ${spotAssetIndex}, market ${pair.name} (szDecimals: ${baseToken.szDecimals})`);
|
|
736
|
+
}
|
|
737
|
+
this.spotMetaLoaded = true;
|
|
738
|
+
this.log(`Loaded ${this.spotAssetMap.size} spot markets`);
|
|
739
|
+
}
|
|
740
|
+
catch (e) {
|
|
741
|
+
this.log('Failed to load spot metadata:', e);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/** Get the spot asset index for a coin, or undefined if not a spot asset */
|
|
745
|
+
getSpotAssetIndex(coin) {
|
|
746
|
+
return this.spotAssetMap.get(coin);
|
|
747
|
+
}
|
|
748
|
+
/** Get the preferred spot market key for a coin (e.g. "@230", "PURR/USDC") */
|
|
749
|
+
getSpotMarketKey(coin) {
|
|
750
|
+
return this.spotPairNameMap.get(coin);
|
|
751
|
+
}
|
|
752
|
+
/** Get spot szDecimals for a coin */
|
|
753
|
+
getSpotSzDecimals(coin) {
|
|
754
|
+
return this.spotSzDecimalsMap.get(coin);
|
|
755
|
+
}
|
|
756
|
+
/** Get all loaded spot asset names */
|
|
757
|
+
getSpotAssetNames() {
|
|
758
|
+
return Array.from(this.spotAssetMap.keys());
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Get user's spot token balances
|
|
762
|
+
*/
|
|
763
|
+
async getSpotBalances(user) {
|
|
764
|
+
this.log('Fetching spotClearinghouseState for:', user ?? this.address);
|
|
765
|
+
const data = await this.postInfo({
|
|
766
|
+
type: 'spotClearinghouseState',
|
|
767
|
+
user: user ?? this.address,
|
|
768
|
+
}, 'spotClearinghouseState');
|
|
769
|
+
this.log('spotClearinghouseState response:', JSON.stringify(data).slice(0, 500));
|
|
770
|
+
return data;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Get token details by token ID
|
|
774
|
+
*/
|
|
775
|
+
async getTokenDetails(tokenId) {
|
|
776
|
+
this.log('Fetching tokenDetails for:', tokenId);
|
|
777
|
+
const baseUrl = isMainnet()
|
|
778
|
+
? 'https://api.hyperliquid.xyz'
|
|
779
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
780
|
+
try {
|
|
781
|
+
const response = await fetch(baseUrl + '/info', {
|
|
782
|
+
method: 'POST',
|
|
783
|
+
headers: { 'Content-Type': 'application/json' },
|
|
784
|
+
body: JSON.stringify({
|
|
785
|
+
type: 'tokenDetails',
|
|
786
|
+
tokenId,
|
|
787
|
+
}),
|
|
788
|
+
});
|
|
789
|
+
const data = await response.json();
|
|
790
|
+
this.log('tokenDetails response:', JSON.stringify(data).slice(0, 500));
|
|
791
|
+
return data;
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Get predicted funding rates across venues
|
|
799
|
+
*/
|
|
800
|
+
async getPredictedFundings() {
|
|
801
|
+
this.log('Fetching predictedFundings...');
|
|
802
|
+
const baseUrl = isMainnet()
|
|
803
|
+
? 'https://api.hyperliquid.xyz'
|
|
804
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
805
|
+
const response = await fetch(baseUrl + '/info', {
|
|
806
|
+
method: 'POST',
|
|
807
|
+
headers: {
|
|
808
|
+
'Content-Type': 'application/json',
|
|
809
|
+
'Cache-Control': 'no-cache, no-store, max-age=0',
|
|
810
|
+
Pragma: 'no-cache',
|
|
811
|
+
},
|
|
812
|
+
body: JSON.stringify({ type: 'predictedFundings' }),
|
|
813
|
+
});
|
|
814
|
+
const data = await response.json();
|
|
815
|
+
this.log('predictedFundings response length:', data?.length);
|
|
816
|
+
return data;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Get L2 order book for an asset
|
|
820
|
+
* Returns best bid/ask and depth
|
|
821
|
+
*/
|
|
822
|
+
async getL2Book(coin) {
|
|
823
|
+
this.log('Fetching l2Book for:', coin);
|
|
824
|
+
// API accepts prefixed names directly (e.g., "xyz:CL")
|
|
825
|
+
let response;
|
|
826
|
+
try {
|
|
827
|
+
response = await this.info.l2Book({ coin });
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
this.log('l2Book failure context:', this.getTransportContext(`l2Book(${coin})`));
|
|
831
|
+
if (error instanceof Error && error.stack) {
|
|
832
|
+
this.log('l2Book stack:', error.stack);
|
|
833
|
+
}
|
|
834
|
+
throw new Error(`l2Book(${coin}) failed: ${this.describeError(error)}`);
|
|
835
|
+
}
|
|
836
|
+
if (!response || !Array.isArray(response.levels)) {
|
|
837
|
+
throw new Error(`l2Book(${coin}) returned empty/malformed payload.`);
|
|
838
|
+
}
|
|
839
|
+
const bids = (response.levels[0] ?? []);
|
|
840
|
+
const asks = (response.levels[1] ?? []);
|
|
841
|
+
const bestBid = bids.length > 0 ? parseFloat(bids[0].px) : 0;
|
|
842
|
+
const bestAsk = asks.length > 0 ? parseFloat(asks[0].px) : 0;
|
|
843
|
+
const midPrice = (bestBid + bestAsk) / 2;
|
|
844
|
+
const spread = bestAsk - bestBid;
|
|
845
|
+
const spreadBps = midPrice > 0 ? (spread / midPrice) * 10000 : 0;
|
|
846
|
+
return {
|
|
847
|
+
bids,
|
|
848
|
+
asks,
|
|
849
|
+
bestBid,
|
|
850
|
+
bestAsk,
|
|
851
|
+
midPrice,
|
|
852
|
+
spread,
|
|
853
|
+
spreadBps,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
async getAssetIndexAsync(coin) {
|
|
857
|
+
let index = this.assetMap.get(coin);
|
|
858
|
+
if (index === undefined && coin.includes(':')) {
|
|
859
|
+
// Try on-demand loading the dex (e.g., "felix:BTC" → load "felix")
|
|
860
|
+
const dexName = coin.split(':')[0];
|
|
861
|
+
await this.loadSingleHip3Dex(dexName);
|
|
862
|
+
index = this.assetMap.get(coin);
|
|
863
|
+
}
|
|
864
|
+
if (index === undefined) {
|
|
865
|
+
const hip3Matches = this.findHip3Matches(coin);
|
|
866
|
+
if (hip3Matches.length > 0) {
|
|
867
|
+
const suggestions = hip3Matches.map(m => `${m}`).join(', ');
|
|
868
|
+
throw new Error(`Unknown asset: ${coin}. Did you mean one of these HIP-3 assets? ${suggestions}\n` +
|
|
869
|
+
`Use "openbroker search --query ${coin}" to find the full ticker.`);
|
|
870
|
+
}
|
|
871
|
+
throw new Error(`Unknown asset: ${coin}. Available: ${Array.from(this.assetMap.keys()).slice(0, 10).join(', ')}...`);
|
|
872
|
+
}
|
|
873
|
+
return index;
|
|
874
|
+
}
|
|
875
|
+
getAssetIndex(coin) {
|
|
876
|
+
const index = this.assetMap.get(coin);
|
|
877
|
+
if (index === undefined) {
|
|
878
|
+
// Check if bare name exists in HIP-3 dexes and suggest prefixed version
|
|
879
|
+
const hip3Matches = this.findHip3Matches(coin);
|
|
880
|
+
if (hip3Matches.length > 0) {
|
|
881
|
+
const suggestions = hip3Matches.map(m => `${m}`).join(', ');
|
|
882
|
+
throw new Error(`Unknown asset: ${coin}. Did you mean one of these HIP-3 assets? ${suggestions}\n` +
|
|
883
|
+
`Use "openbroker search --query ${coin}" to find the full ticker.`);
|
|
884
|
+
}
|
|
885
|
+
throw new Error(`Unknown asset: ${coin}. Available: ${Array.from(this.assetMap.keys()).slice(0, 10).join(', ')}...`);
|
|
886
|
+
}
|
|
887
|
+
return index;
|
|
888
|
+
}
|
|
889
|
+
async getSzDecimalsAsync(coin) {
|
|
890
|
+
let decimals = this.szDecimalsMap.get(coin);
|
|
891
|
+
if (decimals === undefined && coin.includes(':')) {
|
|
892
|
+
const dexName = coin.split(':')[0];
|
|
893
|
+
await this.loadSingleHip3Dex(dexName);
|
|
894
|
+
decimals = this.szDecimalsMap.get(coin);
|
|
895
|
+
}
|
|
896
|
+
if (decimals === undefined) {
|
|
897
|
+
const hip3Matches = this.findHip3Matches(coin);
|
|
898
|
+
if (hip3Matches.length > 0) {
|
|
899
|
+
throw new Error(`Unknown asset: ${coin}. Did you mean: ${hip3Matches.join(', ')}?`);
|
|
900
|
+
}
|
|
901
|
+
throw new Error(`Unknown asset: ${coin}`);
|
|
902
|
+
}
|
|
903
|
+
return decimals;
|
|
904
|
+
}
|
|
905
|
+
getSzDecimals(coin) {
|
|
906
|
+
const decimals = this.szDecimalsMap.get(coin);
|
|
907
|
+
if (decimals === undefined) {
|
|
908
|
+
const hip3Matches = this.findHip3Matches(coin);
|
|
909
|
+
if (hip3Matches.length > 0) {
|
|
910
|
+
throw new Error(`Unknown asset: ${coin}. Did you mean: ${hip3Matches.join(', ')}?`);
|
|
911
|
+
}
|
|
912
|
+
throw new Error(`Unknown asset: ${coin}`);
|
|
913
|
+
}
|
|
914
|
+
return decimals;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Find HIP-3 assets matching a bare coin name (without dex prefix)
|
|
918
|
+
*/
|
|
919
|
+
findHip3Matches(bareName) {
|
|
920
|
+
const matches = [];
|
|
921
|
+
const upperName = bareName.toUpperCase();
|
|
922
|
+
for (const [key, info] of this.coinDexMap.entries()) {
|
|
923
|
+
if (info.dexName && info.localName.toUpperCase() === upperName) {
|
|
924
|
+
matches.push(key);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return matches;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Get the dex name for a coin (null for main dex assets)
|
|
931
|
+
*/
|
|
932
|
+
getCoinDex(coin) {
|
|
933
|
+
return this.coinDexMap.get(coin)?.dexName ?? null;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Get the local (unprefixed) coin name for API calls that need it
|
|
937
|
+
* e.g., "xyz:CL" → "CL", "ETH" → "ETH"
|
|
938
|
+
*/
|
|
939
|
+
getCoinLocalName(coin) {
|
|
940
|
+
return this.coinDexMap.get(coin)?.localName ?? coin;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Check if a coin is a HIP-3 asset
|
|
944
|
+
*/
|
|
945
|
+
isHip3(coin) {
|
|
946
|
+
return this.coinDexMap.get(coin)?.dexName != null;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Invalidate cached metadata so next call fetches fresh data.
|
|
950
|
+
* Useful for long-running strategies that need updated funding rates.
|
|
951
|
+
*/
|
|
952
|
+
invalidateMetaCache() {
|
|
953
|
+
this.meta = null;
|
|
954
|
+
// Keep the asset/szDecimals/coinDex maps - they don't change
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Get all loaded asset names (main + HIP-3)
|
|
958
|
+
*/
|
|
959
|
+
getAllAssetNames() {
|
|
960
|
+
return Array.from(this.assetMap.keys());
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Get all HIP-3 asset names
|
|
964
|
+
*/
|
|
965
|
+
getHip3AssetNames() {
|
|
966
|
+
return Array.from(this.coinDexMap.entries())
|
|
967
|
+
.filter(([_, info]) => info.dexName !== null)
|
|
968
|
+
.map(([name]) => name);
|
|
969
|
+
}
|
|
970
|
+
// ============ Account Info ============
|
|
971
|
+
/**
|
|
972
|
+
* Get the account's abstraction mode.
|
|
973
|
+
* Returns: 'standard' | 'unified' | 'portfolio' | 'dexAbstraction'
|
|
974
|
+
* Unified accounts have a single USDC balance shared across all dexes.
|
|
975
|
+
* Standard accounts have separate balances per dex (need sendAsset transfers).
|
|
976
|
+
*/
|
|
977
|
+
async getAccountMode(user) {
|
|
978
|
+
if (this.accountMode)
|
|
979
|
+
return this.accountMode;
|
|
980
|
+
const baseUrl = isMainnet()
|
|
981
|
+
? 'https://api.hyperliquid.xyz'
|
|
982
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
983
|
+
try {
|
|
984
|
+
const response = await fetch(baseUrl + '/info', {
|
|
985
|
+
method: 'POST',
|
|
986
|
+
headers: { 'Content-Type': 'application/json' },
|
|
987
|
+
body: JSON.stringify({
|
|
988
|
+
type: 'userAbstraction',
|
|
989
|
+
user: user ?? this.address,
|
|
990
|
+
}),
|
|
991
|
+
});
|
|
992
|
+
const data = await response.json();
|
|
993
|
+
this.log('userAbstraction response:', JSON.stringify(data));
|
|
994
|
+
// API may return a bare string or an object. Normalize to string for matching.
|
|
995
|
+
const mode = typeof data === 'string' ? data : (data?.abstraction ?? data?.mode ?? String(data));
|
|
996
|
+
const modeLower = mode.toLowerCase();
|
|
997
|
+
if (modeLower.includes('unified')) {
|
|
998
|
+
this.accountMode = 'unified';
|
|
999
|
+
}
|
|
1000
|
+
else if (modeLower.includes('portfolio')) {
|
|
1001
|
+
this.accountMode = 'portfolio';
|
|
1002
|
+
}
|
|
1003
|
+
else if (modeLower.includes('dex')) {
|
|
1004
|
+
this.accountMode = 'dexAbstraction';
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
// "default" or "disabled" both mean standard mode
|
|
1008
|
+
this.accountMode = 'standard';
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
catch (err) {
|
|
1012
|
+
this.log('Failed to fetch account abstraction mode:', err instanceof Error ? err.message : String(err));
|
|
1013
|
+
this.accountMode = 'standard'; // Safe fallback
|
|
1014
|
+
}
|
|
1015
|
+
this.log('Account mode:', this.accountMode);
|
|
1016
|
+
return this.accountMode;
|
|
1017
|
+
}
|
|
1018
|
+
/** Whether the account uses unified balances (unified or portfolio margin) */
|
|
1019
|
+
async isUnifiedAccount(user) {
|
|
1020
|
+
const mode = await this.getAccountMode(user);
|
|
1021
|
+
return mode === 'unified' || mode === 'portfolio';
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Query the role of an address on HyperCore L1.
|
|
1025
|
+
* Returns: "user" | "agent" | "vault" | "subAccount" | "missing"
|
|
1026
|
+
* Useful for verifying API wallet (agent) registration.
|
|
1027
|
+
*/
|
|
1028
|
+
async getUserRole(address) {
|
|
1029
|
+
const target = address ?? this.address;
|
|
1030
|
+
this.log('Fetching userRole for:', target);
|
|
1031
|
+
try {
|
|
1032
|
+
const response = await this.postInfo({ type: 'userRole', user: target }, 'userRole');
|
|
1033
|
+
this.log('userRole response:', JSON.stringify(response));
|
|
1034
|
+
return response ?? { role: 'missing' };
|
|
1035
|
+
}
|
|
1036
|
+
catch (e) {
|
|
1037
|
+
this.log('userRole query failed:', e);
|
|
1038
|
+
return { role: 'unknown' };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Validate API wallet setup: check that the signing wallet is recognized
|
|
1043
|
+
* as an "agent" on HyperCore and the account address exists.
|
|
1044
|
+
* Logs warnings if misconfigured.
|
|
1045
|
+
*/
|
|
1046
|
+
async validateApiWalletSetup() {
|
|
1047
|
+
const walletResult = await this.getUserRole(this.walletAddress);
|
|
1048
|
+
const accountResult = await this.getUserRole(this.address);
|
|
1049
|
+
const walletRole = walletResult.role;
|
|
1050
|
+
const accountRole = accountResult.role;
|
|
1051
|
+
this.log(`API wallet validation: wallet ${this.walletAddress} role=${walletRole}, account ${this.address} role=${accountRole}`);
|
|
1052
|
+
if (walletRole === 'agent') {
|
|
1053
|
+
const masterAddress = walletResult.data?.user;
|
|
1054
|
+
if (masterAddress && masterAddress.toLowerCase() !== this.address.toLowerCase()) {
|
|
1055
|
+
console.warn(`\x1b[33m⚠️ API wallet ${this.walletAddress} is an agent for ${masterAddress}, but HYPERLIQUID_ACCOUNT_ADDRESS is ${this.address}.\n` +
|
|
1056
|
+
` These should match.\x1b[0m`);
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
this.log(`API wallet confirmed as agent for ${masterAddress ?? this.address}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
console.warn(`\x1b[33m⚠️ API wallet ${this.walletAddress} has role "${walletRole}" on HyperCore (expected "agent").\n` +
|
|
1064
|
+
` Make sure the agent is registered via CoreWriter.registerAgent() on the correct network (${isMainnet() ? 'mainnet' : 'testnet'}).\x1b[0m`);
|
|
1065
|
+
}
|
|
1066
|
+
if (accountRole === 'missing') {
|
|
1067
|
+
console.warn(`\x1b[33m⚠️ Account ${this.address} has role "missing" on HyperCore.\n` +
|
|
1068
|
+
` The account may not exist on ${isMainnet() ? 'mainnet' : 'testnet'} yet. Ensure the contract is deployed and has interacted with HyperCore.\x1b[0m`);
|
|
1069
|
+
}
|
|
1070
|
+
return { valid: walletRole === 'agent', walletRole, accountRole };
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Check if an address has sub-accounts (is a master account)
|
|
1074
|
+
* Sub-accounts cannot approve builder fees - only master accounts can
|
|
1075
|
+
*/
|
|
1076
|
+
async getSubAccounts(user) {
|
|
1077
|
+
this.log('Fetching subAccounts for:', user ?? this.address);
|
|
1078
|
+
try {
|
|
1079
|
+
const response = await this.info.subAccounts({ user: user ?? this.address });
|
|
1080
|
+
if (!response)
|
|
1081
|
+
return [];
|
|
1082
|
+
// Response is an array of sub-account objects
|
|
1083
|
+
return response.map((sub) => ({
|
|
1084
|
+
subAccountUser: sub.subAccountUser,
|
|
1085
|
+
name: sub.name,
|
|
1086
|
+
}));
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
return [];
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Check the maximum builder fee approved for a user/builder pair
|
|
1094
|
+
* Returns the max fee rate as a string (e.g., "0.1%") or null if not approved
|
|
1095
|
+
*/
|
|
1096
|
+
async getMaxBuilderFee(user, builder) {
|
|
1097
|
+
// IMPORTANT: Hyperliquid API requires lowercase addresses
|
|
1098
|
+
const targetUser = (user ?? this.address).toLowerCase();
|
|
1099
|
+
const targetBuilder = (builder ?? this.config.builderAddress).toLowerCase();
|
|
1100
|
+
this.log('Fetching maxBuilderFee for:', targetUser, 'builder:', targetBuilder);
|
|
1101
|
+
try {
|
|
1102
|
+
const baseUrl = isMainnet()
|
|
1103
|
+
? 'https://api.hyperliquid.xyz'
|
|
1104
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1105
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1106
|
+
method: 'POST',
|
|
1107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1108
|
+
body: JSON.stringify({
|
|
1109
|
+
type: 'maxBuilderFee',
|
|
1110
|
+
user: targetUser,
|
|
1111
|
+
builder: targetBuilder,
|
|
1112
|
+
}),
|
|
1113
|
+
});
|
|
1114
|
+
const data = await response.json();
|
|
1115
|
+
this.log('maxBuilderFee response:', data);
|
|
1116
|
+
// API returns a number (fee in tenths of bps) or 0/null if not approved
|
|
1117
|
+
// e.g., 100 = 10 bps = 0.1%
|
|
1118
|
+
if (data !== null && data !== undefined && data !== 0) {
|
|
1119
|
+
// Convert from tenths of bps to percentage string
|
|
1120
|
+
const bps = Number(data) / 10;
|
|
1121
|
+
const pct = bps / 100;
|
|
1122
|
+
return `${pct}%`;
|
|
1123
|
+
}
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
catch (error) {
|
|
1127
|
+
this.log('maxBuilderFee error:', error);
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Approve a builder fee for the open-broker builder
|
|
1133
|
+
* IMPORTANT: This must be signed by a MAIN wallet, not an API wallet or sub-account
|
|
1134
|
+
*
|
|
1135
|
+
* @param maxFeeRate - Max fee rate to approve (e.g., "0.01%" for 1 bps)
|
|
1136
|
+
* @param builder - Builder address (defaults to open-broker builder)
|
|
1137
|
+
*/
|
|
1138
|
+
async approveBuilderFee(maxFeeRate = '0.1%', builder) {
|
|
1139
|
+
const targetBuilder = builder ?? this.config.builderAddress;
|
|
1140
|
+
this.log('Approving builder fee:', maxFeeRate, 'for builder:', targetBuilder);
|
|
1141
|
+
// Check if using API wallet - this won't work
|
|
1142
|
+
if (this.isApiWallet) {
|
|
1143
|
+
return {
|
|
1144
|
+
status: 'err',
|
|
1145
|
+
response: 'Cannot approve builder fee with API wallet. Must use main wallet private key.',
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
// approveBuilderFee is a wallet-level authorization (not a vault action)
|
|
1150
|
+
// and the SDK's `opts` parameter only carries an AbortSignal. Do not pass
|
|
1151
|
+
// vaultParam here — the signature is { signal? }, not a vault wrapper.
|
|
1152
|
+
const response = await this.exchange.approveBuilderFee({
|
|
1153
|
+
builder: targetBuilder,
|
|
1154
|
+
maxFeeRate,
|
|
1155
|
+
});
|
|
1156
|
+
this.log('approveBuilderFee response:', response);
|
|
1157
|
+
return { status: 'ok', response };
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
this.log('approveBuilderFee error:', error);
|
|
1161
|
+
return {
|
|
1162
|
+
status: 'err',
|
|
1163
|
+
response: error instanceof Error ? error.message : String(error),
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Get user funding ledger updates
|
|
1169
|
+
* Returns array of funding payments received/paid per position
|
|
1170
|
+
*/
|
|
1171
|
+
async getUserFunding(user, startTime, endTime) {
|
|
1172
|
+
this.log('Fetching userFunding for:', user ?? this.address);
|
|
1173
|
+
const baseUrl = isMainnet()
|
|
1174
|
+
? 'https://api.hyperliquid.xyz'
|
|
1175
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1176
|
+
const body = {
|
|
1177
|
+
type: 'userFunding',
|
|
1178
|
+
user: user ?? this.address,
|
|
1179
|
+
};
|
|
1180
|
+
if (startTime !== undefined)
|
|
1181
|
+
body.startTime = startTime;
|
|
1182
|
+
if (endTime !== undefined)
|
|
1183
|
+
body.endTime = endTime;
|
|
1184
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1185
|
+
method: 'POST',
|
|
1186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1187
|
+
body: JSON.stringify(body),
|
|
1188
|
+
});
|
|
1189
|
+
const data = await response.json();
|
|
1190
|
+
this.log('userFunding response length:', data?.length);
|
|
1191
|
+
return data;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Get user trade fills
|
|
1195
|
+
*/
|
|
1196
|
+
async getUserFills(user, aggregateByTime) {
|
|
1197
|
+
this.log('Fetching userFills for:', user ?? this.address);
|
|
1198
|
+
const baseUrl = isMainnet()
|
|
1199
|
+
? 'https://api.hyperliquid.xyz'
|
|
1200
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1201
|
+
const body = {
|
|
1202
|
+
type: 'userFills',
|
|
1203
|
+
user: user ?? this.address,
|
|
1204
|
+
};
|
|
1205
|
+
if (aggregateByTime !== undefined)
|
|
1206
|
+
body.aggregateByTime = aggregateByTime;
|
|
1207
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1208
|
+
method: 'POST',
|
|
1209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1210
|
+
body: JSON.stringify(body),
|
|
1211
|
+
});
|
|
1212
|
+
const data = await response.json();
|
|
1213
|
+
this.log('userFills response length:', data?.length);
|
|
1214
|
+
return data;
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Get historical orders (all statuses)
|
|
1218
|
+
*/
|
|
1219
|
+
async getHistoricalOrders(user) {
|
|
1220
|
+
this.log('Fetching historicalOrders for:', user ?? this.address);
|
|
1221
|
+
const baseUrl = isMainnet()
|
|
1222
|
+
? 'https://api.hyperliquid.xyz'
|
|
1223
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1224
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1225
|
+
method: 'POST',
|
|
1226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1227
|
+
body: JSON.stringify({
|
|
1228
|
+
type: 'historicalOrders',
|
|
1229
|
+
user: user ?? this.address,
|
|
1230
|
+
}),
|
|
1231
|
+
});
|
|
1232
|
+
const data = await response.json();
|
|
1233
|
+
this.log('historicalOrders response length:', data?.length);
|
|
1234
|
+
return data;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Get status of a specific order by OID or CLOID
|
|
1238
|
+
*/
|
|
1239
|
+
async getOrderStatus(oid, user) {
|
|
1240
|
+
this.log('Fetching orderStatus for oid:', oid);
|
|
1241
|
+
const baseUrl = isMainnet()
|
|
1242
|
+
? 'https://api.hyperliquid.xyz'
|
|
1243
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1244
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1245
|
+
method: 'POST',
|
|
1246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1247
|
+
body: JSON.stringify({
|
|
1248
|
+
type: 'orderStatus',
|
|
1249
|
+
user: user ?? this.address,
|
|
1250
|
+
oid: typeof oid === 'string' ? oid : oid,
|
|
1251
|
+
}),
|
|
1252
|
+
});
|
|
1253
|
+
const data = await response.json();
|
|
1254
|
+
this.log('orderStatus response:', JSON.stringify(data).slice(0, 500));
|
|
1255
|
+
return data;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Get user fee schedule and volume info
|
|
1259
|
+
*/
|
|
1260
|
+
async getUserFees(user) {
|
|
1261
|
+
this.log('Fetching userFees for:', user ?? this.address);
|
|
1262
|
+
const baseUrl = isMainnet()
|
|
1263
|
+
? 'https://api.hyperliquid.xyz'
|
|
1264
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1265
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1266
|
+
method: 'POST',
|
|
1267
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1268
|
+
body: JSON.stringify({
|
|
1269
|
+
type: 'userFees',
|
|
1270
|
+
user: user ?? this.address,
|
|
1271
|
+
}),
|
|
1272
|
+
});
|
|
1273
|
+
const data = await response.json();
|
|
1274
|
+
this.log('userFees response:', JSON.stringify(data).slice(0, 500));
|
|
1275
|
+
return data;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Get OHLCV candle data for an asset
|
|
1279
|
+
*/
|
|
1280
|
+
async getCandleSnapshot(coin, interval, startTime, endTime) {
|
|
1281
|
+
this.log('Fetching candleSnapshot for:', coin, interval);
|
|
1282
|
+
const baseUrl = isMainnet()
|
|
1283
|
+
? 'https://api.hyperliquid.xyz'
|
|
1284
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1285
|
+
// API accepts prefixed names directly (e.g., "xyz:CL")
|
|
1286
|
+
const req = { coin, interval, startTime };
|
|
1287
|
+
if (endTime !== undefined)
|
|
1288
|
+
req.endTime = endTime;
|
|
1289
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1290
|
+
method: 'POST',
|
|
1291
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1292
|
+
body: JSON.stringify({ type: 'candleSnapshot', req }),
|
|
1293
|
+
});
|
|
1294
|
+
const data = await response.json();
|
|
1295
|
+
this.log('candleSnapshot response length:', data?.length);
|
|
1296
|
+
return data;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Get historical funding rates for an asset
|
|
1300
|
+
*/
|
|
1301
|
+
async getFundingHistory(coin, startTime, endTime) {
|
|
1302
|
+
this.log('Fetching fundingHistory for:', coin);
|
|
1303
|
+
const baseUrl = isMainnet()
|
|
1304
|
+
? 'https://api.hyperliquid.xyz'
|
|
1305
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1306
|
+
// API accepts prefixed names directly (e.g., "xyz:CL")
|
|
1307
|
+
const body = { type: 'fundingHistory', coin, startTime };
|
|
1308
|
+
if (endTime !== undefined)
|
|
1309
|
+
body.endTime = endTime;
|
|
1310
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1311
|
+
method: 'POST',
|
|
1312
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1313
|
+
body: JSON.stringify(body),
|
|
1314
|
+
});
|
|
1315
|
+
const data = await response.json();
|
|
1316
|
+
this.log('fundingHistory response length:', data?.length);
|
|
1317
|
+
return data;
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Get recent trades for an asset
|
|
1321
|
+
*/
|
|
1322
|
+
async getRecentTrades(coin) {
|
|
1323
|
+
this.log('Fetching recentTrades for:', coin);
|
|
1324
|
+
const baseUrl = isMainnet()
|
|
1325
|
+
? 'https://api.hyperliquid.xyz'
|
|
1326
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1327
|
+
// API accepts prefixed names directly (e.g., "xyz:CL")
|
|
1328
|
+
const body = { type: 'recentTrades', coin };
|
|
1329
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1330
|
+
method: 'POST',
|
|
1331
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1332
|
+
body: JSON.stringify(body),
|
|
1333
|
+
});
|
|
1334
|
+
const data = await response.json();
|
|
1335
|
+
this.log('recentTrades response length:', data?.length);
|
|
1336
|
+
return data;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Get user API rate limit status
|
|
1340
|
+
*/
|
|
1341
|
+
async getUserRateLimit(user) {
|
|
1342
|
+
this.log('Fetching userRateLimit for:', user ?? this.address);
|
|
1343
|
+
const baseUrl = isMainnet()
|
|
1344
|
+
? 'https://api.hyperliquid.xyz'
|
|
1345
|
+
: 'https://api.hyperliquid-testnet.xyz';
|
|
1346
|
+
const response = await fetch(baseUrl + '/info', {
|
|
1347
|
+
method: 'POST',
|
|
1348
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1349
|
+
body: JSON.stringify({
|
|
1350
|
+
type: 'userRateLimit',
|
|
1351
|
+
user: user ?? this.address,
|
|
1352
|
+
}),
|
|
1353
|
+
});
|
|
1354
|
+
const data = await response.json();
|
|
1355
|
+
this.log('userRateLimit response:', JSON.stringify(data));
|
|
1356
|
+
return data;
|
|
1357
|
+
}
|
|
1358
|
+
async getUserState(user, dex) {
|
|
1359
|
+
this.log('Fetching clearinghouseState for:', user ?? this.address, dex ? `dex: ${dex}` : '');
|
|
1360
|
+
const params = { user: user ?? this.address };
|
|
1361
|
+
if (dex !== undefined)
|
|
1362
|
+
params.dex = dex;
|
|
1363
|
+
const label = dex ? `clearinghouseState(${dex})` : 'clearinghouseState(main)';
|
|
1364
|
+
let response;
|
|
1365
|
+
try {
|
|
1366
|
+
response = await this.withRetry(() => this.info.clearinghouseState(params), label);
|
|
1367
|
+
}
|
|
1368
|
+
catch (error) {
|
|
1369
|
+
this.log(`${label} failure context:`, this.getTransportContext(label));
|
|
1370
|
+
if (error instanceof Error && error.stack) {
|
|
1371
|
+
this.log(`${label} stack:`, error.stack);
|
|
1372
|
+
}
|
|
1373
|
+
throw new Error(`${label} failed: ${this.describeError(error)}`);
|
|
1374
|
+
}
|
|
1375
|
+
// The SDK response has `withdrawable` as a top-level field, not inside
|
|
1376
|
+
// marginSummary/crossMarginSummary. Copy it into our MarginSummary shape.
|
|
1377
|
+
const state = response;
|
|
1378
|
+
const withdrawable = response.withdrawable ?? '0';
|
|
1379
|
+
if (state.marginSummary) {
|
|
1380
|
+
state.marginSummary.withdrawable = withdrawable;
|
|
1381
|
+
}
|
|
1382
|
+
if (state.crossMarginSummary) {
|
|
1383
|
+
state.crossMarginSummary.withdrawable = withdrawable;
|
|
1384
|
+
}
|
|
1385
|
+
return state;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Get user state across all dexes (main + HIP-3).
|
|
1389
|
+
* For unified accounts: equity comes from spotClearinghouseState (single USDC balance).
|
|
1390
|
+
* For standard accounts: aggregates margin summaries from each dex.
|
|
1391
|
+
*/
|
|
1392
|
+
async getUserStateAll(user) {
|
|
1393
|
+
await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
|
|
1394
|
+
const unified = await this.isUnifiedAccount(user);
|
|
1395
|
+
const mainState = await this.getUserState(user);
|
|
1396
|
+
// Collect positions from all HIP-3 dexes (in parallel; testnet: only loaded dexes)
|
|
1397
|
+
const validDexs = await this.getIterableHip3Dexs();
|
|
1398
|
+
const dexResults = await this.batchSettled(validDexs.map((dex) => async () => {
|
|
1399
|
+
const dexState = await this.getUserState(user, dex.name);
|
|
1400
|
+
return { dex, dexState };
|
|
1401
|
+
}));
|
|
1402
|
+
let hip3Errors = 0;
|
|
1403
|
+
const safeAdd = (a, b) => {
|
|
1404
|
+
const va = parseFloat(a ?? '0') || 0;
|
|
1405
|
+
const vb = parseFloat(b ?? '0') || 0;
|
|
1406
|
+
return String(va + vb);
|
|
1407
|
+
};
|
|
1408
|
+
for (const result of dexResults) {
|
|
1409
|
+
if (result.status === 'rejected') {
|
|
1410
|
+
hip3Errors++;
|
|
1411
|
+
this.log(`Failed to fetch state for HIP-3 dex:`, result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
const { dexState } = result.value;
|
|
1415
|
+
if (dexState.assetPositions?.length > 0) {
|
|
1416
|
+
mainState.assetPositions.push(...dexState.assetPositions);
|
|
1417
|
+
}
|
|
1418
|
+
// For standard accounts, aggregate margin from each dex
|
|
1419
|
+
if (!unified) {
|
|
1420
|
+
const dexMargin = dexState.marginSummary;
|
|
1421
|
+
if (dexMargin) {
|
|
1422
|
+
const addToSummary = (summary) => {
|
|
1423
|
+
summary.accountValue = safeAdd(summary.accountValue, dexMargin.accountValue);
|
|
1424
|
+
summary.totalNtlPos = safeAdd(summary.totalNtlPos, dexMargin.totalNtlPos);
|
|
1425
|
+
summary.totalRawUsd = safeAdd(summary.totalRawUsd, dexMargin.totalRawUsd);
|
|
1426
|
+
summary.totalMarginUsed = safeAdd(summary.totalMarginUsed, dexMargin.totalMarginUsed);
|
|
1427
|
+
summary.withdrawable = safeAdd(summary.withdrawable, dexMargin.withdrawable);
|
|
1428
|
+
};
|
|
1429
|
+
addToSummary(mainState.marginSummary);
|
|
1430
|
+
addToSummary(mainState.crossMarginSummary);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
if (hip3Errors > 0) {
|
|
1435
|
+
this.log(`Warning: ${hip3Errors} HIP-3 dex queries failed — some positions may be missing. Use --verbose for details.`);
|
|
1436
|
+
}
|
|
1437
|
+
// For unified accounts: equity is the USDC balance from spot clearinghouse
|
|
1438
|
+
if (unified) {
|
|
1439
|
+
try {
|
|
1440
|
+
const spotState = await this.getSpotBalances(user);
|
|
1441
|
+
this.log('Unified spot balances:', JSON.stringify(spotState));
|
|
1442
|
+
// Find USDC balance (case-insensitive, handles variations)
|
|
1443
|
+
const balances = spotState?.balances ?? [];
|
|
1444
|
+
const usdcBalance = balances.find(b => b.coin?.toUpperCase() === 'USDC');
|
|
1445
|
+
if (usdcBalance) {
|
|
1446
|
+
const totalUsdc = usdcBalance.total;
|
|
1447
|
+
const holdUsdc = usdcBalance.hold;
|
|
1448
|
+
const withdrawable = String(parseFloat(totalUsdc) - parseFloat(holdUsdc));
|
|
1449
|
+
// Compute total margin used and notional from all positions
|
|
1450
|
+
let totalMarginUsed = 0;
|
|
1451
|
+
let totalNtlPos = 0;
|
|
1452
|
+
for (const ap of mainState.assetPositions) {
|
|
1453
|
+
const pos = ap.position;
|
|
1454
|
+
if (parseFloat(pos.szi) === 0)
|
|
1455
|
+
continue;
|
|
1456
|
+
totalMarginUsed += parseFloat(pos.marginUsed);
|
|
1457
|
+
totalNtlPos += Math.abs(parseFloat(pos.positionValue));
|
|
1458
|
+
}
|
|
1459
|
+
const summary = {
|
|
1460
|
+
accountValue: totalUsdc,
|
|
1461
|
+
totalNtlPos: String(totalNtlPos),
|
|
1462
|
+
totalRawUsd: totalUsdc,
|
|
1463
|
+
totalMarginUsed: String(totalMarginUsed),
|
|
1464
|
+
withdrawable,
|
|
1465
|
+
};
|
|
1466
|
+
mainState.marginSummary = summary;
|
|
1467
|
+
mainState.crossMarginSummary = { ...summary };
|
|
1468
|
+
this.log(`Unified account: USDC balance $${parseFloat(totalUsdc).toFixed(2)}, margin used $${totalMarginUsed.toFixed(2)}`);
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
this.log('Unified account: no USDC balance found in spot state. Balances:', balances.map(b => b.coin));
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
this.log('Failed to fetch spot balances for unified account:', err instanceof Error ? err.message : String(err));
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return mainState;
|
|
1479
|
+
}
|
|
1480
|
+
async getOpenOrders(user) {
|
|
1481
|
+
this.log('Fetching openOrders for:', user ?? this.address);
|
|
1482
|
+
await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
|
|
1483
|
+
// Fetch main dex orders
|
|
1484
|
+
const orders = await this.withRetry(() => this.info.openOrders({ user: user ?? this.address }), 'openOrders');
|
|
1485
|
+
// Fetch HIP-3 dex orders (in parallel; testnet: only loaded dexes)
|
|
1486
|
+
const validDexs = await this.getIterableHip3Dexs();
|
|
1487
|
+
const dexResults = await this.batchSettled(validDexs.map((dex) => async () => {
|
|
1488
|
+
const dexOrders = await this.info.openOrders({ user: user ?? this.address, dex: dex.name });
|
|
1489
|
+
return { dex, dexOrders };
|
|
1490
|
+
}));
|
|
1491
|
+
for (const result of dexResults) {
|
|
1492
|
+
if (result.status === 'rejected') {
|
|
1493
|
+
this.log('Failed to fetch open orders for HIP-3 dex:', result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
const { dex, dexOrders } = result.value;
|
|
1497
|
+
if (dexOrders.length > 0) {
|
|
1498
|
+
this.log(`Found ${dexOrders.length} open orders on HIP-3 dex ${dex.name}`);
|
|
1499
|
+
orders.push(...dexOrders);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
return orders;
|
|
1503
|
+
}
|
|
1504
|
+
// ============ Trading ============
|
|
1505
|
+
/**
|
|
1506
|
+
* HIP-3 perps: prepare for trading.
|
|
1507
|
+
* 1. Set isolated margin mode (required for HIP-3)
|
|
1508
|
+
* 2. For standard accounts only: transfer USDC from main perp to HIP-3 dex
|
|
1509
|
+
* (unified accounts share USDC across all dexes automatically)
|
|
1510
|
+
*/
|
|
1511
|
+
async ensureHip3Ready(coin, notional, leverage) {
|
|
1512
|
+
if (!this.isHip3(coin))
|
|
1513
|
+
return;
|
|
1514
|
+
const dexInfo = this.coinDexMap.get(coin);
|
|
1515
|
+
if (!dexInfo?.dexName)
|
|
1516
|
+
return;
|
|
1517
|
+
const maxLev = this.hip3MaxLeverageMap.get(coin) ?? 10;
|
|
1518
|
+
const effectiveLev = Math.min(leverage ?? maxLev, maxLev);
|
|
1519
|
+
// Set isolated margin on first order per asset, or when leverage changes
|
|
1520
|
+
if (!this.hip3IsolatedSet.has(coin) || leverage) {
|
|
1521
|
+
this.log(`HIP-3 asset ${coin} (dex: ${dexInfo.dexName}) — setting isolated margin at ${effectiveLev}x`);
|
|
1522
|
+
try {
|
|
1523
|
+
await this.updateLeverage(coin, effectiveLev, false); // false = isolated
|
|
1524
|
+
this.hip3IsolatedSet.add(coin);
|
|
1525
|
+
}
|
|
1526
|
+
catch (err) {
|
|
1527
|
+
this.log(`Failed to set isolated margin for ${coin}:`, err instanceof Error ? err.message : String(err));
|
|
1528
|
+
this.hip3IsolatedSet.add(coin);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
// Unified accounts share USDC across all dexes — no transfer needed
|
|
1532
|
+
const unified = await this.isUnifiedAccount();
|
|
1533
|
+
if (unified) {
|
|
1534
|
+
this.log(`Unified account — skipping USDC transfer for ${coin} (shared balance)`);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
// Standard accounts: transfer USDC to the HIP-3 dex to cover margin
|
|
1538
|
+
const requiredMargin = notional / effectiveLev;
|
|
1539
|
+
// Add 20% buffer for fees and slippage
|
|
1540
|
+
const transferAmount = Math.ceil(requiredMargin * 1.2 * 100) / 100;
|
|
1541
|
+
this.log(`HIP-3 margin transfer: ${transferAmount} USDC from main → ${dexInfo.dexName} (notional: ${notional}, leverage: ${effectiveLev}x)`);
|
|
1542
|
+
try {
|
|
1543
|
+
await this.exchange.sendAsset({
|
|
1544
|
+
destination: this.address,
|
|
1545
|
+
sourceDex: '', // main perp dex
|
|
1546
|
+
destinationDex: dexInfo.dexName,
|
|
1547
|
+
token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054',
|
|
1548
|
+
amount: String(transferAmount),
|
|
1549
|
+
}, this.vaultParam);
|
|
1550
|
+
this.log(`Transferred ${transferAmount} USDC to ${dexInfo.dexName} dex`);
|
|
1551
|
+
}
|
|
1552
|
+
catch (err) {
|
|
1553
|
+
// Log but don't block — dex may already have sufficient balance
|
|
1554
|
+
this.log(`Margin transfer to ${dexInfo.dexName} failed:`, err instanceof Error ? err.message : String(err));
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
async order(coin, isBuy, size, price, orderType, reduceOnly = false, includeBuilder = true, leverage) {
|
|
1558
|
+
await this.requireTrading();
|
|
1559
|
+
await this.getMetaAndAssetCtxs();
|
|
1560
|
+
// Set leverage if specified (for main perps, cross margin; for HIP-3, handled in ensureHip3Ready)
|
|
1561
|
+
if (leverage && !this.isHip3(coin)) {
|
|
1562
|
+
this.log(`Setting leverage for ${coin} to ${leverage}x cross`);
|
|
1563
|
+
await this.updateLeverage(coin, leverage, true);
|
|
1564
|
+
}
|
|
1565
|
+
// HIP-3 perps: set isolated margin + transfer USDC to dex
|
|
1566
|
+
await this.ensureHip3Ready(coin, size * price, leverage);
|
|
1567
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
1568
|
+
const szDecimals = this.getSzDecimals(coin);
|
|
1569
|
+
const orderWire = {
|
|
1570
|
+
a: assetIndex,
|
|
1571
|
+
b: isBuy,
|
|
1572
|
+
p: roundPrice(price, szDecimals),
|
|
1573
|
+
s: roundSize(size, szDecimals),
|
|
1574
|
+
r: reduceOnly,
|
|
1575
|
+
t: orderType,
|
|
1576
|
+
};
|
|
1577
|
+
this.log('Placing order:', JSON.stringify(orderWire, null, 2));
|
|
1578
|
+
const orderRequest = {
|
|
1579
|
+
orders: [orderWire],
|
|
1580
|
+
grouping: 'na',
|
|
1581
|
+
};
|
|
1582
|
+
// Add builder fee if configured (skip on testnet — builder may not be approved)
|
|
1583
|
+
if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
|
|
1584
|
+
orderRequest.builder = this.builderInfo;
|
|
1585
|
+
this.log('Including builder fee:', this.builderInfo);
|
|
1586
|
+
}
|
|
1587
|
+
try {
|
|
1588
|
+
const response = await this.exchange.order(orderRequest, this.vaultParam);
|
|
1589
|
+
this.log('Order response:', JSON.stringify(response, null, 2));
|
|
1590
|
+
return response;
|
|
1591
|
+
}
|
|
1592
|
+
catch (error) {
|
|
1593
|
+
this.log('Order error:', error);
|
|
1594
|
+
// Return error in our format
|
|
1595
|
+
return {
|
|
1596
|
+
status: 'err',
|
|
1597
|
+
response: error instanceof Error ? error.message : String(error),
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
async marketOrder(coin, isBuy, size, slippageBps, leverage) {
|
|
1602
|
+
await this.getMetaAndAssetCtxs();
|
|
1603
|
+
// Get current mid price
|
|
1604
|
+
const mids = await this.getAllMids();
|
|
1605
|
+
const midPrice = parseFloat(mids[coin]);
|
|
1606
|
+
if (!midPrice) {
|
|
1607
|
+
throw new Error(`No mid price for ${coin}. Check if the asset exists.`);
|
|
1608
|
+
}
|
|
1609
|
+
// Calculate slippage price
|
|
1610
|
+
const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
|
|
1611
|
+
const limitPrice = isBuy
|
|
1612
|
+
? midPrice * (1 + slippage)
|
|
1613
|
+
: midPrice * (1 - slippage);
|
|
1614
|
+
this.log(`Market order: ${coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice}, slippage: ${slippage * 100}%)`);
|
|
1615
|
+
return this.order(coin, isBuy, size, limitPrice, { limit: { tif: 'Ioc' } }, false, true, leverage);
|
|
1616
|
+
}
|
|
1617
|
+
async limitOrder(coin, isBuy, size, price, tif = 'Gtc', reduceOnly = false, leverage) {
|
|
1618
|
+
return this.order(coin, isBuy, size, price, { limit: { tif } }, reduceOnly, true, leverage);
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Place a trigger order (stop loss or take profit)
|
|
1622
|
+
* @param coin - Asset to trade
|
|
1623
|
+
* @param isBuy - True for buy, false for sell
|
|
1624
|
+
* @param size - Order size
|
|
1625
|
+
* @param triggerPrice - Price at which the order triggers
|
|
1626
|
+
* @param limitPrice - Limit price for the order (use triggerPrice for market-like execution)
|
|
1627
|
+
* @param tpsl - 'tp' for take profit, 'sl' for stop loss
|
|
1628
|
+
* @param reduceOnly - Whether order is reduce-only (should be true for TP/SL)
|
|
1629
|
+
*/
|
|
1630
|
+
async triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, tpsl, reduceOnly = true, leverage) {
|
|
1631
|
+
await this.requireTrading();
|
|
1632
|
+
await this.getMetaAndAssetCtxs();
|
|
1633
|
+
// Set leverage if specified (for main perps)
|
|
1634
|
+
if (leverage && !this.isHip3(coin)) {
|
|
1635
|
+
this.log(`Setting leverage for ${coin} to ${leverage}x cross`);
|
|
1636
|
+
await this.updateLeverage(coin, leverage, true);
|
|
1637
|
+
}
|
|
1638
|
+
// HIP-3 perps: set isolated margin + transfer USDC to dex
|
|
1639
|
+
await this.ensureHip3Ready(coin, size * limitPrice, leverage);
|
|
1640
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
1641
|
+
const szDecimals = this.getSzDecimals(coin);
|
|
1642
|
+
// For trigger orders, we use the trigger order type
|
|
1643
|
+
// isMarket: false means it becomes a limit order at limitPrice when triggered
|
|
1644
|
+
// For stop loss, we typically want some slippage protection
|
|
1645
|
+
const orderWire = {
|
|
1646
|
+
a: assetIndex,
|
|
1647
|
+
b: isBuy,
|
|
1648
|
+
p: roundPrice(limitPrice, szDecimals),
|
|
1649
|
+
s: roundSize(size, szDecimals),
|
|
1650
|
+
r: reduceOnly,
|
|
1651
|
+
t: {
|
|
1652
|
+
trigger: {
|
|
1653
|
+
triggerPx: roundPrice(triggerPrice, szDecimals),
|
|
1654
|
+
isMarket: false,
|
|
1655
|
+
tpsl,
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
this.log('Placing trigger order:', JSON.stringify(orderWire, null, 2));
|
|
1660
|
+
const orderRequest = {
|
|
1661
|
+
orders: [orderWire],
|
|
1662
|
+
grouping: 'na',
|
|
1663
|
+
};
|
|
1664
|
+
// Add builder fee if configured (skip on testnet — builder may not be approved)
|
|
1665
|
+
if (!this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
|
|
1666
|
+
orderRequest.builder = this.builderInfo;
|
|
1667
|
+
this.log('Including builder fee:', this.builderInfo);
|
|
1668
|
+
}
|
|
1669
|
+
try {
|
|
1670
|
+
const response = await this.exchange.order(orderRequest, this.vaultParam);
|
|
1671
|
+
this.log('Trigger order response:', JSON.stringify(response, null, 2));
|
|
1672
|
+
return response;
|
|
1673
|
+
}
|
|
1674
|
+
catch (error) {
|
|
1675
|
+
this.log('Trigger order error:', error);
|
|
1676
|
+
return {
|
|
1677
|
+
status: 'err',
|
|
1678
|
+
response: error instanceof Error ? error.message : String(error),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Place a stop loss order
|
|
1684
|
+
*/
|
|
1685
|
+
async stopLoss(coin, isBuy, size, triggerPrice, slippageBps = 100 // 1% slippage for SL execution
|
|
1686
|
+
) {
|
|
1687
|
+
// For stop loss, limit price should be worse than trigger to ensure fill
|
|
1688
|
+
// Buy SL: limit above trigger, Sell SL: limit below trigger
|
|
1689
|
+
const slippageMult = slippageBps / 10000;
|
|
1690
|
+
const limitPrice = isBuy
|
|
1691
|
+
? triggerPrice * (1 + slippageMult)
|
|
1692
|
+
: triggerPrice * (1 - slippageMult);
|
|
1693
|
+
return this.triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, 'sl', true);
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Place a take profit order
|
|
1697
|
+
*/
|
|
1698
|
+
async takeProfit(coin, isBuy, size, triggerPrice) {
|
|
1699
|
+
// For take profit, we can use the same price as trigger (it's a favorable price)
|
|
1700
|
+
return this.triggerOrder(coin, isBuy, size, triggerPrice, triggerPrice, 'tp', true);
|
|
1701
|
+
}
|
|
1702
|
+
async cancel(coin, oid) {
|
|
1703
|
+
await this.requireTrading();
|
|
1704
|
+
await this.getMetaAndAssetCtxs();
|
|
1705
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
1706
|
+
this.log(`Cancelling order: ${coin} (asset ${assetIndex}) oid ${oid}`);
|
|
1707
|
+
try {
|
|
1708
|
+
const response = await this.exchange.cancel({
|
|
1709
|
+
cancels: [{ a: assetIndex, o: oid }],
|
|
1710
|
+
}, this.vaultParam);
|
|
1711
|
+
this.log('Cancel response:', JSON.stringify(response, null, 2));
|
|
1712
|
+
return response;
|
|
1713
|
+
}
|
|
1714
|
+
catch (error) {
|
|
1715
|
+
this.log('Cancel error:', error);
|
|
1716
|
+
return {
|
|
1717
|
+
status: 'err',
|
|
1718
|
+
response: { type: 'cancel', data: { statuses: [error instanceof Error ? error.message : String(error)] } },
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
async cancelAll(coin) {
|
|
1723
|
+
const orders = await this.getOpenOrders();
|
|
1724
|
+
const results = [];
|
|
1725
|
+
for (const order of orders) {
|
|
1726
|
+
if (coin && order.coin !== coin)
|
|
1727
|
+
continue;
|
|
1728
|
+
const result = await this.cancel(order.coin, order.oid);
|
|
1729
|
+
results.push(result);
|
|
1730
|
+
}
|
|
1731
|
+
return results;
|
|
1732
|
+
}
|
|
1733
|
+
// ============ Spot Trading ============
|
|
1734
|
+
/**
|
|
1735
|
+
* Place a spot order.
|
|
1736
|
+
* Uses the same exchange.order() endpoint but with spot asset indices (10000 + spotIndex).
|
|
1737
|
+
* Spot orders have no leverage, no reduce-only, and builder fee max is 1000 (vs 100 for perps).
|
|
1738
|
+
*
|
|
1739
|
+
* @param coin - Base token symbol (e.g. "PURR", "HYPE")
|
|
1740
|
+
* @param isBuy - True to buy base token, false to sell
|
|
1741
|
+
* @param size - Size in base token units
|
|
1742
|
+
* @param price - Limit price in quote token (usually USDC)
|
|
1743
|
+
* @param orderType - Order type with time-in-force
|
|
1744
|
+
* @param includeBuilder - Whether to include builder fee (default: true)
|
|
1745
|
+
*/
|
|
1746
|
+
async spotOrder(coin, isBuy, size, price, orderType, includeBuilder = true) {
|
|
1747
|
+
await this.requireTrading();
|
|
1748
|
+
await this.loadSpotMeta();
|
|
1749
|
+
const assetIndex = this.spotAssetMap.get(coin);
|
|
1750
|
+
if (assetIndex === undefined) {
|
|
1751
|
+
throw new Error(`Unknown spot asset: ${coin}. Available: ${Array.from(this.spotAssetMap.keys()).slice(0, 15).join(', ')}...\n` +
|
|
1752
|
+
`Use "openbroker spot" to see all spot markets.`);
|
|
1753
|
+
}
|
|
1754
|
+
const szDecimals = this.spotSzDecimalsMap.get(coin);
|
|
1755
|
+
const orderWire = {
|
|
1756
|
+
a: assetIndex,
|
|
1757
|
+
b: isBuy,
|
|
1758
|
+
p: roundPrice(price, szDecimals, true),
|
|
1759
|
+
s: roundSize(size, szDecimals),
|
|
1760
|
+
r: false, // reduce-only not applicable for spot
|
|
1761
|
+
t: orderType,
|
|
1762
|
+
};
|
|
1763
|
+
this.log('Placing spot order:', JSON.stringify(orderWire, null, 2));
|
|
1764
|
+
const orderRequest = {
|
|
1765
|
+
orders: [orderWire],
|
|
1766
|
+
grouping: 'na',
|
|
1767
|
+
};
|
|
1768
|
+
// Add builder fee if configured (skip on testnet — builder may not be approved)
|
|
1769
|
+
if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
|
|
1770
|
+
orderRequest.builder = this.builderInfo;
|
|
1771
|
+
this.log('Including builder fee:', this.builderInfo);
|
|
1772
|
+
}
|
|
1773
|
+
try {
|
|
1774
|
+
const response = await this.exchange.order(orderRequest, this.vaultParam);
|
|
1775
|
+
this.log('Spot order response:', JSON.stringify(response, null, 2));
|
|
1776
|
+
return response;
|
|
1777
|
+
}
|
|
1778
|
+
catch (error) {
|
|
1779
|
+
this.log('Spot order error:', error);
|
|
1780
|
+
return {
|
|
1781
|
+
status: 'err',
|
|
1782
|
+
response: error instanceof Error ? error.message : String(error),
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Place a spot market order (IOC at slippage price).
|
|
1788
|
+
* @param coin - Base token symbol (e.g. "PURR", "HYPE")
|
|
1789
|
+
* @param isBuy - True to buy, false to sell
|
|
1790
|
+
* @param size - Size in base token units
|
|
1791
|
+
* @param slippageBps - Slippage tolerance in basis points (default: config value)
|
|
1792
|
+
*/
|
|
1793
|
+
async spotMarketOrder(coin, isBuy, size, slippageBps) {
|
|
1794
|
+
await this.loadSpotMeta();
|
|
1795
|
+
const assetIndex = this.spotAssetMap.get(coin);
|
|
1796
|
+
const spotCoinKey = this.spotPairNameMap.get(coin);
|
|
1797
|
+
if (assetIndex === undefined || !spotCoinKey) {
|
|
1798
|
+
throw new Error(`Unknown spot asset: ${coin}. Use "openbroker spot" to see available markets.`);
|
|
1799
|
+
}
|
|
1800
|
+
// Use the exact spot market key from spotMeta (e.g. "@230", "PURR/USDC").
|
|
1801
|
+
// On testnet the tradable asset id and displayed market key can diverge.
|
|
1802
|
+
const mids = await this.getAllMids();
|
|
1803
|
+
// Record<string, string> lookup returns `string` under TS defaults but
|
|
1804
|
+
// is runtime-undefined when the key is absent — hence the explicit union.
|
|
1805
|
+
let midStr = mids[spotCoinKey];
|
|
1806
|
+
// Fallback: allMids may omit spot pairs (especially on testnet).
|
|
1807
|
+
// Try spotMetaAndAssetCtxs which returns markPx directly.
|
|
1808
|
+
if (!midStr) {
|
|
1809
|
+
this.log(`allMids missing spot key "${spotCoinKey}", falling back to spotMetaAndAssetCtxs`);
|
|
1810
|
+
try {
|
|
1811
|
+
const spotData = await this.getSpotMetaAndAssetCtxs();
|
|
1812
|
+
const ctxMap = new Map();
|
|
1813
|
+
for (const ctx of spotData.assetCtxs) {
|
|
1814
|
+
if (ctx.coin)
|
|
1815
|
+
ctxMap.set(ctx.coin, ctx.midPx || ctx.markPx);
|
|
1816
|
+
}
|
|
1817
|
+
midStr = ctxMap.get(spotCoinKey);
|
|
1818
|
+
}
|
|
1819
|
+
catch (e) {
|
|
1820
|
+
this.log(`spotMetaAndAssetCtxs fallback failed:`, e);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
const midPrice = midStr ? parseFloat(midStr) : 0;
|
|
1824
|
+
if (!midPrice || midPrice === 0) {
|
|
1825
|
+
throw new Error(`No spot price for ${coin} (${spotCoinKey}). Check if the spot market exists with "openbroker spot --coin ${coin}".`);
|
|
1826
|
+
}
|
|
1827
|
+
// Calculate slippage price
|
|
1828
|
+
const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
|
|
1829
|
+
const limitPrice = isBuy
|
|
1830
|
+
? midPrice * (1 + slippage)
|
|
1831
|
+
: midPrice * (1 - slippage);
|
|
1832
|
+
this.log(`Spot market order: ${coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice}, slippage: ${slippage * 100}%)`);
|
|
1833
|
+
return this.spotOrder(coin, isBuy, size, limitPrice, { limit: { tif: 'Ioc' } });
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Place a spot limit order.
|
|
1837
|
+
* @param coin - Base token symbol (e.g. "PURR", "HYPE")
|
|
1838
|
+
* @param isBuy - True to buy, false to sell
|
|
1839
|
+
* @param size - Size in base token units
|
|
1840
|
+
* @param price - Limit price in quote token (usually USDC)
|
|
1841
|
+
* @param tif - Time-in-force (default: Gtc)
|
|
1842
|
+
*/
|
|
1843
|
+
async spotLimitOrder(coin, isBuy, size, price, tif = 'Gtc') {
|
|
1844
|
+
return this.spotOrder(coin, isBuy, size, price, { limit: { tif } });
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Place a HIP-4 outcome order.
|
|
1848
|
+
* Outcome assets are spot-like, but encoded as:
|
|
1849
|
+
* encoding = 10 * outcome + side
|
|
1850
|
+
* assetId = 100_000_000 + encoding
|
|
1851
|
+
* coin = #<encoding>
|
|
1852
|
+
*
|
|
1853
|
+
* Side 0 is usually YES and side 1 is usually NO, per outcomeMeta.sideSpecs.
|
|
1854
|
+
*/
|
|
1855
|
+
async outcomeOrder(outcomeRef, outcomeSide, isBuy, size, price, orderType, includeBuilder = true, szDecimalsOverride) {
|
|
1856
|
+
await this.requireTrading();
|
|
1857
|
+
const resolved = this.resolveOutcomeRef(outcomeRef, outcomeSide);
|
|
1858
|
+
const szDecimals = szDecimalsOverride ?? await this.getOutcomeSzDecimals(resolved.outcome, resolved.side);
|
|
1859
|
+
const orderWire = {
|
|
1860
|
+
a: resolved.assetId,
|
|
1861
|
+
b: isBuy,
|
|
1862
|
+
p: roundPrice(price, szDecimals, true),
|
|
1863
|
+
s: roundSize(size, szDecimals),
|
|
1864
|
+
r: false,
|
|
1865
|
+
t: orderType,
|
|
1866
|
+
};
|
|
1867
|
+
this.log('Placing outcome order:', JSON.stringify({ resolved, orderWire }, null, 2));
|
|
1868
|
+
const orderRequest = {
|
|
1869
|
+
orders: [orderWire],
|
|
1870
|
+
grouping: 'na',
|
|
1871
|
+
};
|
|
1872
|
+
if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
|
|
1873
|
+
orderRequest.builder = this.builderInfo;
|
|
1874
|
+
this.log('Including builder fee:', this.builderInfo);
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
const response = await this.exchange.order(orderRequest, this.vaultParam);
|
|
1878
|
+
this.log('Outcome order response:', JSON.stringify(response, null, 2));
|
|
1879
|
+
return response;
|
|
1880
|
+
}
|
|
1881
|
+
catch (error) {
|
|
1882
|
+
this.log('Outcome order error:', error);
|
|
1883
|
+
return {
|
|
1884
|
+
status: 'err',
|
|
1885
|
+
response: error instanceof Error ? error.message : String(error),
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
async outcomeMarketOrder(outcomeRef, outcomeSide, isBuy, size, slippageBps, szDecimalsOverride) {
|
|
1890
|
+
const resolved = this.resolveOutcomeRef(outcomeRef, outcomeSide);
|
|
1891
|
+
const midPrice = await this.getOutcomeMidPrice(resolved.outcome, resolved.side);
|
|
1892
|
+
const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
|
|
1893
|
+
const limitPrice = isBuy
|
|
1894
|
+
? midPrice * (1 + slippage)
|
|
1895
|
+
: midPrice * (1 - slippage);
|
|
1896
|
+
this.log(`Outcome market order: ${resolved.coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice})`);
|
|
1897
|
+
return this.outcomeOrder(outcomeRef, outcomeSide, isBuy, size, limitPrice, { limit: { tif: 'Ioc' } }, true, szDecimalsOverride);
|
|
1898
|
+
}
|
|
1899
|
+
async outcomeLimitOrder(outcomeRef, outcomeSide, isBuy, size, price, tif = 'Gtc', szDecimalsOverride) {
|
|
1900
|
+
return this.outcomeOrder(outcomeRef, outcomeSide, isBuy, size, price, { limit: { tif } }, true, szDecimalsOverride);
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Cancel a spot order by coin and order ID.
|
|
1904
|
+
*/
|
|
1905
|
+
async spotCancel(coin, oid) {
|
|
1906
|
+
await this.requireTrading();
|
|
1907
|
+
await this.loadSpotMeta();
|
|
1908
|
+
const assetIndex = this.spotAssetMap.get(coin);
|
|
1909
|
+
if (assetIndex === undefined) {
|
|
1910
|
+
throw new Error(`Unknown spot asset: ${coin}`);
|
|
1911
|
+
}
|
|
1912
|
+
this.log(`Cancelling spot order: ${coin} (asset ${assetIndex}) oid ${oid}`);
|
|
1913
|
+
try {
|
|
1914
|
+
const response = await this.exchange.cancel({
|
|
1915
|
+
cancels: [{ a: assetIndex, o: oid }],
|
|
1916
|
+
}, this.vaultParam);
|
|
1917
|
+
this.log('Spot cancel response:', JSON.stringify(response, null, 2));
|
|
1918
|
+
return response;
|
|
1919
|
+
}
|
|
1920
|
+
catch (error) {
|
|
1921
|
+
this.log('Spot cancel error:', error);
|
|
1922
|
+
return {
|
|
1923
|
+
status: 'err',
|
|
1924
|
+
response: { type: 'cancel', data: { statuses: [error instanceof Error ? error.message : String(error)] } },
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
// ============ Leverage ============
|
|
1929
|
+
async updateLeverage(coin, leverage, isCross = true) {
|
|
1930
|
+
await this.requireTrading();
|
|
1931
|
+
await this.getMetaAndAssetCtxs();
|
|
1932
|
+
// HIP-3 perps only support isolated margin — override isCross and clamp leverage
|
|
1933
|
+
if (this.isHip3(coin)) {
|
|
1934
|
+
if (isCross) {
|
|
1935
|
+
this.log(`HIP-3 asset ${coin} does not support cross margin — forcing isolated`);
|
|
1936
|
+
}
|
|
1937
|
+
isCross = false;
|
|
1938
|
+
const maxLev = this.hip3MaxLeverageMap.get(coin) ?? 10;
|
|
1939
|
+
if (leverage > maxLev) {
|
|
1940
|
+
this.log(`HIP-3 asset ${coin} max leverage is ${maxLev}x — clamping from ${leverage}x`);
|
|
1941
|
+
leverage = maxLev;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
1945
|
+
this.log(`Updating leverage: ${coin} (asset ${assetIndex}) to ${leverage}x ${isCross ? 'cross' : 'isolated'}`);
|
|
1946
|
+
try {
|
|
1947
|
+
const response = await this.exchange.updateLeverage({
|
|
1948
|
+
asset: assetIndex,
|
|
1949
|
+
isCross,
|
|
1950
|
+
leverage,
|
|
1951
|
+
}, this.vaultParam);
|
|
1952
|
+
this.log('Leverage response:', JSON.stringify(response, null, 2));
|
|
1953
|
+
return response;
|
|
1954
|
+
}
|
|
1955
|
+
catch (error) {
|
|
1956
|
+
this.log('Leverage error:', error);
|
|
1957
|
+
throw error;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Place a native Hyperliquid TWAP order.
|
|
1962
|
+
* The exchange handles slicing and timing server-side.
|
|
1963
|
+
* @param coin Asset symbol (e.g. "ETH")
|
|
1964
|
+
* @param isBuy true for long, false for short
|
|
1965
|
+
* @param size Total size in base currency
|
|
1966
|
+
* @param durationMinutes Duration in minutes (5–1440)
|
|
1967
|
+
* @param randomize Enable random order timing
|
|
1968
|
+
* @param reduceOnly Reduce-only flag
|
|
1969
|
+
* @param leverage Optional leverage to set before placing the TWAP
|
|
1970
|
+
*/
|
|
1971
|
+
async twapOrder(coin, isBuy, size, durationMinutes, randomize = true, reduceOnly = false, leverage) {
|
|
1972
|
+
await this.getMetaAndAssetCtxs();
|
|
1973
|
+
if (leverage) {
|
|
1974
|
+
await this.updateLeverage(coin, leverage);
|
|
1975
|
+
}
|
|
1976
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
1977
|
+
const roundedSize = roundSize(size, this.getSzDecimals(coin));
|
|
1978
|
+
this.log(`TWAP order: ${coin} (asset ${assetIndex}) ${isBuy ? 'BUY' : 'SELL'} ${roundedSize} over ${durationMinutes}m, randomize=${randomize}, reduceOnly=${reduceOnly}`);
|
|
1979
|
+
try {
|
|
1980
|
+
const response = await this.exchange.twapOrder({
|
|
1981
|
+
twap: {
|
|
1982
|
+
a: assetIndex,
|
|
1983
|
+
b: isBuy,
|
|
1984
|
+
s: String(roundedSize),
|
|
1985
|
+
r: reduceOnly,
|
|
1986
|
+
m: durationMinutes,
|
|
1987
|
+
t: randomize,
|
|
1988
|
+
},
|
|
1989
|
+
}, this.vaultParam);
|
|
1990
|
+
this.log('TWAP order response:', JSON.stringify(response, null, 2));
|
|
1991
|
+
return response;
|
|
1992
|
+
}
|
|
1993
|
+
catch (error) {
|
|
1994
|
+
this.log('TWAP order error:', error);
|
|
1995
|
+
throw error;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Cancel a running TWAP order.
|
|
2000
|
+
* @param coin Asset symbol (e.g. "ETH")
|
|
2001
|
+
* @param twapId The TWAP order ID to cancel
|
|
2002
|
+
*/
|
|
2003
|
+
async twapCancel(coin, twapId) {
|
|
2004
|
+
await this.getMetaAndAssetCtxs();
|
|
2005
|
+
const assetIndex = this.getAssetIndex(coin);
|
|
2006
|
+
this.log(`TWAP cancel: ${coin} (asset ${assetIndex}) twapId=${twapId}`);
|
|
2007
|
+
try {
|
|
2008
|
+
const response = await this.exchange.twapCancel({
|
|
2009
|
+
a: assetIndex,
|
|
2010
|
+
t: twapId,
|
|
2011
|
+
}, this.vaultParam);
|
|
2012
|
+
this.log('TWAP cancel response:', JSON.stringify(response, null, 2));
|
|
2013
|
+
return response;
|
|
2014
|
+
}
|
|
2015
|
+
catch (error) {
|
|
2016
|
+
this.log('TWAP cancel error:', error);
|
|
2017
|
+
throw error;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Get TWAP order history for the current user.
|
|
2022
|
+
*/
|
|
2023
|
+
async twapHistory() {
|
|
2024
|
+
const response = await this.info.twapHistory({ user: this.address });
|
|
2025
|
+
this.log('TWAP history:', JSON.stringify(response, null, 2));
|
|
2026
|
+
return response;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
// Singleton instance
|
|
2030
|
+
let clientInstance = null;
|
|
2031
|
+
export function getClient(config) {
|
|
2032
|
+
if (!clientInstance) {
|
|
2033
|
+
clientInstance = new HyperliquidClient(config);
|
|
2034
|
+
}
|
|
2035
|
+
return clientInstance;
|
|
2036
|
+
}
|
|
2037
|
+
// Reset client (useful for testing)
|
|
2038
|
+
export function resetClient() {
|
|
2039
|
+
clientInstance = null;
|
|
2040
|
+
}
|