@reefclaw/openclaw-plugin 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ // Hyperliquid public market-data source (keyless) — Phase 1 of
2
+ // docs/HYPERLIQUID_INTEGRATION_PLAN.md. Implements PublicMarketDataApi so
3
+ // PAPER mode on venue='hyperliquid' prices the simulator, the stop-watcher,
4
+ // and every market-data tool from Hyperliquid instead of Binance. Live
5
+ // trading does NOT use this class (the Phase 3 adapter owns its own reads).
6
+ //
7
+ // Protocol facts (verified 2026-07-11, plan §3 + live probe):
8
+ // - REST is POST-only: /info on api.hyperliquid.xyz (testnet:
9
+ // api.hyperliquid-testnet.xyz). CCXT@4.5.37 wraps everything we need.
10
+ // - `exchangeStatus` (weight 2) returns `{specialStatuses, time}` — the
11
+ // reachability probe AND a clock-drift source in one call.
12
+ // - metaAndAssetCtxs-class info requests are weight 20 and return data for
13
+ // ALL assets at once → fetchTicker is served from one cached fetchTickers
14
+ // upstream call (TTL below), so N symbols cost the same as one. The full
15
+ // address/IP rate gate is a Phase 3 concern (live cadences); paper-mode
16
+ // cadence here is bounded by the cache: ≤ (60s/TTL) weight-20 calls/min
17
+ // (~300/1200 IP budget at the 4s default).
18
+ // - CCXT auto-monetization landmine (plan §3.8): initializeClient() only
19
+ // fires on AUTHENTICATED clients, and this class is keyless — but we pin
20
+ // `builderFee:false, refSet:true` in options anyway so a future
21
+ // credentialed refactor can never silently enroll CCXT's builder fee or
22
+ // referral code. hl-public.test.ts asserts these options forever.
23
+ //
24
+ // Error contract mirrors BinancePublicApi/IntelPublicApi: null on ANY failure,
25
+ // never throw to consumers. Binance-shaped symbols (…/USDT) are a caller bug
26
+ // on this venue — logged clearly, null returned (never silently translated).
27
+ import { createRequire } from 'node:module';
28
+ import { toCcxtSymbol } from '@reefclaw/shared';
29
+ import { logger } from '../../logger.js';
30
+ const TAG = 'hl-public';
31
+ // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
32
+ // (same pattern as binance-public.ts / binance-private.ts).
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ let ccxtCjs;
35
+ try {
36
+ const _require = createRequire(import.meta.url);
37
+ ccxtCjs = _require('ccxt');
38
+ }
39
+ catch {
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ ccxtCjs = require('ccxt');
42
+ }
43
+ /** Base URLs verified against the official docs + pinned ccxt source (plan §3.1). */
44
+ const HL_MAINNET_API = 'https://api.hyperliquid.xyz';
45
+ const HL_TESTNET_API = 'https://api.hyperliquid-testnet.xyz';
46
+ const DEFAULT_TICKER_TTL_MS = 4_000;
47
+ function resolveTickerTtlMs() {
48
+ const raw = Number(process.env.RC_HL_TICKER_TTL_MS);
49
+ if (!Number.isFinite(raw) || raw < 500)
50
+ return DEFAULT_TICKER_TTL_MS;
51
+ return raw;
52
+ }
53
+ export class HyperliquidPublicApi {
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ exchange;
56
+ testnet;
57
+ fetchImpl;
58
+ tickerTtlMs = resolveTickerTtlMs();
59
+ /** One upstream fetchTickers call serves every symbol within the TTL. */
60
+ tickersCache = null;
61
+ tickersInflight = null;
62
+ constructor(opts = {}) {
63
+ this.testnet = opts.testnet === true;
64
+ this.fetchImpl = opts.fetchImpl ?? fetch;
65
+ if (opts.exchange) {
66
+ this.exchange = opts.exchange;
67
+ }
68
+ else {
69
+ const HlClass = ccxtCjs.hyperliquid ?? ccxtCjs.default?.hyperliquid;
70
+ if (!HlClass) {
71
+ throw new Error('CCXT hyperliquid class not found — check ccxt version');
72
+ }
73
+ this.exchange = new HlClass({
74
+ enableRateLimit: true,
75
+ options: {
76
+ // ★ Never let CCXT enroll its own builder fee / referral code —
77
+ // plan §3.8. Keyless clients can't sign those actions anyway, but
78
+ // the pin is deliberate defense-in-depth for future refactors.
79
+ builderFee: false,
80
+ refSet: true,
81
+ },
82
+ });
83
+ if (this.testnet) {
84
+ this.exchange.setSandboxMode(true);
85
+ }
86
+ }
87
+ logger.info(TAG, `Hyperliquid public API initialized (no auth${this.testnet ? ', TESTNET' : ''})`);
88
+ }
89
+ baseUrl() {
90
+ return this.testnet ? HL_TESTNET_API : HL_MAINNET_API;
91
+ }
92
+ /** Canonical/ccxt symbol → this venue's ccxt symbol, or null (logged) when
93
+ * the symbol isn't a Hyperliquid USDC perp — a caller bug we surface
94
+ * loudly rather than silently translating quote assets. */
95
+ venueSymbol(symbol, ctx) {
96
+ try {
97
+ return toCcxtSymbol('hyperliquid', symbol);
98
+ }
99
+ catch (err) {
100
+ logger.warn(TAG, `${ctx}(${symbol}) — not a Hyperliquid symbol (${err instanceof Error ? err.message : String(err)}); use USDC pairs like BTC/USDC`);
101
+ return null;
102
+ }
103
+ }
104
+ /** Fetch-all-tickers with a short TTL + inflight dedup. Returns a map keyed
105
+ * by ccxt symbol, or null on failure. */
106
+ async getTickers() {
107
+ const now = Date.now();
108
+ if (this.tickersCache && now - this.tickersCache.at < this.tickerTtlMs) {
109
+ return this.tickersCache.bySymbol;
110
+ }
111
+ if (this.tickersInflight)
112
+ return this.tickersInflight;
113
+ this.tickersInflight = (async () => {
114
+ try {
115
+ const raw = await this.exchange.fetchTickers();
116
+ const bySymbol = new Map(Object.entries(raw ?? {}));
117
+ this.tickersCache = { at: Date.now(), bySymbol };
118
+ return bySymbol;
119
+ }
120
+ catch (err) {
121
+ logger.error(TAG, `fetchTickers failed: ${err instanceof Error ? err.message : String(err)}`);
122
+ return null;
123
+ }
124
+ finally {
125
+ this.tickersInflight = null;
126
+ }
127
+ })();
128
+ return this.tickersInflight;
129
+ }
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ async fetchTickerRaw(symbol) {
132
+ const vs = this.venueSymbol(symbol, 'fetchTickerRaw');
133
+ if (!vs)
134
+ return null;
135
+ const tickers = await this.getTickers();
136
+ return tickers?.get(vs) ?? null;
137
+ }
138
+ /** Ticker from the cached all-assets snapshot. Hyperliquid's asset contexts
139
+ * carry mark/mid rather than a trade-tape bid/ask; absent fields fall back
140
+ * to `last` with zero modeled spread — the paper fill engine models
141
+ * slippage itself (same convention as IntelPublicApi). */
142
+ async fetchTicker(symbol) {
143
+ const vs = this.venueSymbol(symbol, 'fetchTicker');
144
+ if (!vs)
145
+ return null;
146
+ const tickers = await this.getTickers();
147
+ const raw = tickers?.get(vs);
148
+ if (!raw) {
149
+ if (tickers)
150
+ logger.warn(TAG, `fetchTicker(${symbol}) — ${vs} not in Hyperliquid universe`);
151
+ return null;
152
+ }
153
+ const last = Number(raw.last ?? raw.close ?? raw.markPrice ?? NaN);
154
+ if (!Number.isFinite(last) || last <= 0) {
155
+ logger.warn(TAG, `fetchTicker(${symbol}) — no usable price on ticker`);
156
+ return null;
157
+ }
158
+ const timestamp = Number.isFinite(raw.timestamp) ? Number(raw.timestamp) : Date.now();
159
+ return {
160
+ // Key on the symbol the CALLER used so per-symbol maps line up.
161
+ symbol,
162
+ last,
163
+ bid: Number(raw.bid ?? NaN) > 0 ? Number(raw.bid) : last,
164
+ ask: Number(raw.ask ?? NaN) > 0 ? Number(raw.ask) : last,
165
+ baseVolume: Number(raw.baseVolume ?? 0) || 0,
166
+ quoteVolume: Number(raw.quoteVolume ?? 0) || 0,
167
+ change: Number(raw.change ?? 0) || 0,
168
+ percentage: Number(raw.percentage ?? 0) || 0,
169
+ timestamp,
170
+ datetime: raw.datetime ?? new Date(timestamp).toISOString(),
171
+ };
172
+ }
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ async fetchFundingRate(symbol) {
175
+ const vs = this.venueSymbol(symbol, 'fetchFundingRate');
176
+ if (!vs)
177
+ return null;
178
+ try {
179
+ const r = await this.exchange.fetchFundingRate(vs);
180
+ return r ?? null;
181
+ }
182
+ catch (err) {
183
+ logger.error(TAG, `fetchFundingRate(${symbol}) failed: ${err instanceof Error ? err.message : String(err)}`);
184
+ return null;
185
+ }
186
+ }
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ async fetchOpenInterest(symbol) {
189
+ const vs = this.venueSymbol(symbol, 'fetchOpenInterest');
190
+ if (!vs)
191
+ return null;
192
+ try {
193
+ const r = await this.exchange.fetchOpenInterest(vs);
194
+ return r ?? null;
195
+ }
196
+ catch (err) {
197
+ logger.error(TAG, `fetchOpenInterest(${symbol}) failed: ${err instanceof Error ? err.message : String(err)}`);
198
+ return null;
199
+ }
200
+ }
201
+ /** l2Book — Hyperliquid serves at most 20 levels/side (plan §3.6). */
202
+ async fetchOrderBook(symbol, limit = 20) {
203
+ const vs = this.venueSymbol(symbol, 'fetchOrderBook');
204
+ if (!vs)
205
+ return null;
206
+ try {
207
+ const raw = await this.exchange.fetchOrderBook(vs, Math.min(limit, 20));
208
+ return {
209
+ bids: (raw.bids ?? []).map((l) => [l[0], l[1]]),
210
+ asks: (raw.asks ?? []).map((l) => [l[0], l[1]]),
211
+ timestamp: raw.timestamp ?? Date.now(),
212
+ };
213
+ }
214
+ catch (err) {
215
+ logger.error(TAG, `fetchOrderBook(${symbol}) failed: ${err instanceof Error ? err.message : String(err)}`);
216
+ return null;
217
+ }
218
+ }
219
+ /** candleSnapshot — only the most recent 5000 candles exist per (coin,
220
+ * interval) (plan §3.6); requests inside that window behave like Binance. */
221
+ async fetchOHLCV(symbol, timeframe = '1h', limit = 100) {
222
+ const vs = this.venueSymbol(symbol, 'fetchOHLCV');
223
+ if (!vs)
224
+ return null;
225
+ try {
226
+ const raw = await this.exchange.fetchOHLCV(vs, timeframe, undefined, limit);
227
+ const valid = [];
228
+ for (const candle of raw) {
229
+ if (candle.length >= 6 &&
230
+ typeof candle[0] === 'number' &&
231
+ typeof candle[1] === 'number' &&
232
+ typeof candle[2] === 'number' &&
233
+ typeof candle[3] === 'number' &&
234
+ typeof candle[4] === 'number' &&
235
+ typeof candle[5] === 'number') {
236
+ valid.push(candle);
237
+ }
238
+ }
239
+ return valid;
240
+ }
241
+ catch (err) {
242
+ logger.error(TAG, `fetchOHLCV(${symbol}, ${timeframe}) failed: ${err instanceof Error ? err.message : String(err)}`);
243
+ return null;
244
+ }
245
+ }
246
+ /** Probe Hyperliquid reachability from this host — the readiness gate's
247
+ * venue signal, mirroring BinancePublicApi.probeReachability's outcome
248
+ * shape. Hand-rolled POST /info `exchangeStatus` (weight 2) so the probe
249
+ * has zero ccxt-method-shape dependence; the response's `time` field
250
+ * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
251
+ * as the clock-drift source. Geo classification is best-effort — HL's
252
+ * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
253
+ * geo_blocked, anything else non-2xx/network maps to unreachable. */
254
+ async probeReachability() {
255
+ const ac = new AbortController();
256
+ const tid = setTimeout(() => ac.abort(), 10_000);
257
+ try {
258
+ const res = await this.fetchImpl(`${this.baseUrl()}/info`, {
259
+ method: 'POST',
260
+ headers: { 'content-type': 'application/json' },
261
+ body: JSON.stringify({ type: 'exchangeStatus' }),
262
+ signal: ac.signal,
263
+ });
264
+ if (res.status === 451 || res.status === 403) {
265
+ logger.warn(TAG, `probeReachability: HTTP ${res.status} (geo/policy block)`);
266
+ return { outcome: 'geo_blocked', driftMs: null };
267
+ }
268
+ if (res.status < 200 || res.status >= 300) {
269
+ logger.warn(TAG, `probeReachability: HTTP ${res.status}`);
270
+ return { outcome: 'unreachable', driftMs: null };
271
+ }
272
+ const body = (await res.json());
273
+ const serverTime = Number(body?.time);
274
+ const driftMs = Number.isFinite(serverTime) ? serverTime - Date.now() : null;
275
+ return { outcome: 'reachable', driftMs };
276
+ }
277
+ catch (err) {
278
+ logger.warn(TAG, `probeReachability failed: ${err instanceof Error ? err.message : String(err)}`);
279
+ return { outcome: 'unreachable', driftMs: null };
280
+ }
281
+ finally {
282
+ clearTimeout(tid);
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,24 @@
1
+ import { fillExchangeId, parseVenue, type VenueId } from '@reefclaw/shared';
2
+ import { LiveAdapter } from '../live/live-adapter.js';
3
+ export type { VenueId };
4
+ export { fillExchangeId, parseVenue };
5
+ /** Venues this build can construct a LIVE adapter for. PAPER mode is
6
+ * venue-flavored only by its market-data source (PaperMarketFeed / chart) and
7
+ * is not gated here. */
8
+ export declare const SUPPORTED_LIVE_VENUES: ReadonlySet<VenueId>;
9
+ export declare function isLiveVenueSupported(venue: VenueId): boolean;
10
+ /** Paper-wallet settlement/quote currency for a venue. Hyperliquid margins
11
+ * USDC linear perps; Binance USDⓈ-M paper keeps the historical USDT.
12
+ * Issue #174: the paper wallet MUST follow the venue — a USDT wallet on the
13
+ * USDC venue reads as $0 equity and every order fails "Insufficient USDC". */
14
+ export declare function venueQuoteCurrency(venue: VenueId): 'USDT' | 'USDC';
15
+ type BinanceLiveAdapterArgs = ConstructorParameters<typeof LiveAdapter>;
16
+ /** Construct the live adapter for a venue.
17
+ *
18
+ * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to
19
+ * the inline construction this factory replaced (Phase 0 no-behavior-change
20
+ * rule; the args tuple is derived from the constructor so the two can never
21
+ * drift). Hyperliquid: throws — reaching this arm means the boot-time
22
+ * isLiveVenueSupported() fallback-to-PAPER gate was bypassed, which is a bug,
23
+ * not a user state. */
24
+ export declare function createLiveAdapter(venue: VenueId, ...args: BinanceLiveAdapterArgs): LiveAdapter;
@@ -0,0 +1,47 @@
1
+ // Venue registry — the single seam where a trading venue's LIVE adapter is
2
+ // constructed (Phase 0 of docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.1/§7.1).
3
+ //
4
+ // Phase 0 scope: Binance is the ONLY venue this build can trade live;
5
+ // 'hyperliquid' is a recognised-but-unsupported config value that boot handles
6
+ // by falling back to PAPER (never by crashing register() — OpenClaw treats a
7
+ // throwing register as "ignored" and the agent silently loses every tool).
8
+ // Phase 3 adds the HyperliquidLiveAdapter arm HERE and nowhere else, so the
9
+ // boot path never grows a second venue branch.
10
+ //
11
+ // Venue is LOCAL mechanism (TOOL_DISTRIBUTION_ARCHITECTURE.md §2 decision
12
+ // rule: it holds keys + is part of the safety floor) — it is read from
13
+ // ~/.reefclaw/plugin-config.json `exchange.venue` and MUST never be settable
14
+ // from the central config channel.
15
+ import { fillExchangeId, parseVenue } from '@reefclaw/shared';
16
+ import { LiveAdapter } from '../live/live-adapter.js';
17
+ export { fillExchangeId, parseVenue };
18
+ /** Venues this build can construct a LIVE adapter for. PAPER mode is
19
+ * venue-flavored only by its market-data source (PaperMarketFeed / chart) and
20
+ * is not gated here. */
21
+ export const SUPPORTED_LIVE_VENUES = new Set(['binance']);
22
+ export function isLiveVenueSupported(venue) {
23
+ return SUPPORTED_LIVE_VENUES.has(venue);
24
+ }
25
+ /** Paper-wallet settlement/quote currency for a venue. Hyperliquid margins
26
+ * USDC linear perps; Binance USDⓈ-M paper keeps the historical USDT.
27
+ * Issue #174: the paper wallet MUST follow the venue — a USDT wallet on the
28
+ * USDC venue reads as $0 equity and every order fails "Insufficient USDC". */
29
+ export function venueQuoteCurrency(venue) {
30
+ return venue === 'hyperliquid' ? 'USDC' : 'USDT';
31
+ }
32
+ /** Construct the live adapter for a venue.
33
+ *
34
+ * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to
35
+ * the inline construction this factory replaced (Phase 0 no-behavior-change
36
+ * rule; the args tuple is derived from the constructor so the two can never
37
+ * drift). Hyperliquid: throws — reaching this arm means the boot-time
38
+ * isLiveVenueSupported() fallback-to-PAPER gate was bypassed, which is a bug,
39
+ * not a user state. */
40
+ export function createLiveAdapter(venue, ...args) {
41
+ switch (venue) {
42
+ case 'binance':
43
+ return new LiveAdapter(...args);
44
+ case 'hyperliquid':
45
+ throw new Error("venue 'hyperliquid' has no live adapter in this build — arrives in Phase 3 of docs/HYPERLIQUID_INTEGRATION_PLAN.md");
46
+ }
47
+ }