@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
@@ -1,3 +1,4 @@
1
+ import { type VenueReachabilityResult } from '@reefclaw/shared';
1
2
  import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
3
  import type { OrderBookDepth } from '../simulator/types.js';
3
4
  import type { PublicMarketDataApi } from './public-market-data-api.js';
@@ -18,17 +19,30 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
18
19
  fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
19
20
  /** Probe Binance USD-M FUTURES reachability from this host — the readiness
20
21
  * gate's core signal. Calls the futures-explicit implicit method so it hits
21
- * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
22
- * `api.binance.com`, since the public client doesn't set defaultType:'future').
22
+ * `fapi.binance.com` regardless of how the instance is configured. Keep it
23
+ * explicit even though the client now sets `defaultType: 'future'`: this
24
+ * probe must detect a futures-specific 451 geo-block, and it must not start
25
+ * silently probing spot if that option is ever changed.
23
26
  * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
24
27
  * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
25
28
  * noteBinanceError). Outcomes:
26
29
  * - 'reachable' clean response (driftMs = serverTime − localTime)
27
30
  * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
28
31
  * - 'unknown' the ban/weight gate paused us — NOT a host problem
29
- * - 'unreachable' network / DNS / timeout / other error */
30
- probeReachability(): Promise<{
31
- outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
32
- driftMs: number | null;
33
- }>;
32
+ * - 'stalled' THIS process was starved we learned nothing (issue #265)
33
+ * - 'unreachable' network / DNS / timeout / other error
34
+ *
35
+ * ★ Self-stall detection: an error is evidence about BINANCE only if it
36
+ * arrived on schedule. When the host starves the process the loop stops
37
+ * running, ccxt's own timeout lands minutes late, and the old code blamed the
38
+ * network — producing a confident "you are geo-blocked / check your firewall"
39
+ * banner while the agent was in fact trading normally. Past
40
+ * REACHABILITY_STALL_FACTOR× the request budget we report `stalled` and the
41
+ * reporter renders it `unknown`. A 451 still wins: the server ANSWERED, so
42
+ * that classification stands however late we noticed it. */
43
+ probeReachability(): Promise<VenueReachabilityResult>;
44
+ /** The request budget the stall yardstick is measured against. ccxt owns the
45
+ * actual timeout, so read it from the instance rather than hardcoding a
46
+ * second copy that could silently drift from it. */
47
+ private probeTimeoutMs;
34
48
  }
@@ -1,8 +1,39 @@
1
1
  // Thin wrapper around CCXT for Binance public API endpoints.
2
2
  // No API keys required — only uses public market data.
3
+ //
4
+ // ★ USD-M FUTURES, NOT SPOT. ReefClaw trades Binance USD-M perpetuals, so every
5
+ // price this client serves must come from `fapi.binance.com`. `ccxt.binance` is
6
+ // the combined spot+futures class and defaults to SPOT, so without an explicit
7
+ // `defaultType: 'future'` the symbol-resolving reads (fetchTicker / fetchOHLCV /
8
+ // fetchOrderBook) silently resolved to the spot market. That was wrong in two
9
+ // ways at once:
10
+ // 1. SILENT: every pair with a spot twin (BTC/USDT, ETH/USDT, …) returned
11
+ // SPOT prices on a perp product — charts, paper marks and paper fills all
12
+ // priced off the wrong book, diverging from the perp by the basis.
13
+ // 2. LOUD: the 17 futures-ONLY contracts (1000FLOKI, 1000PEPE, 1000SHIB,
14
+ // 1000BONK, 1000SATS, 1000RATS, 1000LUNC, 1000XEC, 1000CAT, …) have no
15
+ // spot market, so every fetch threw `binance does not have market symbol`.
16
+ // A user holding one got a dead feed: 100% ticker failure, marks frozen at
17
+ // entry, unrealized stuck at 0, and the frozen NAV then fed a phantom
18
+ // RED-zone drawdown -> auto-flatten. Observed on a live customer box
19
+ // 2026-07-25 (1000FLOKI/USDT, 120/120 failed polls).
20
+ // `fetchFundingRate` / `fetchOpenInterest` were always futures-correct (ccxt
21
+ // routes them to fapi regardless) and `probeReachability` calls the futures
22
+ // endpoint explicitly, so only the three symbol-resolving reads change.
23
+ //
24
+ // Weights are unchanged and already futures-calibrated in binance-ban-gate.ts
25
+ // (ceiling 2000/2400 = the USD-M IP cap). Doc-verified against
26
+ // https://developers.binance.com/docs/derivatives/usds-margined-futures/market-data/rest-api
27
+ // (2026-07-25): host `fapi.binance.com`; /fapi/v1/ticker/24hr weight 1 for a
28
+ // single symbol (gate charges 2), /fapi/v1/klines weight 1-10 by limit (gate
29
+ // charges 2), /fapi/v1/depth weight 2-20 by limit (gate charges 5) — the gate
30
+ // stays conservative on all three, so this never under-counts. It does mean the
31
+ // calls now genuinely consume the futures budget the gate was already charging
32
+ // them against (spot and futures have separate IP weight pools).
3
33
  import { createRequire } from 'node:module';
4
34
  import { logger } from '../logger.js';
5
35
  import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
36
+ import { REACHABILITY_STALL_FACTOR } from '@reefclaw/shared';
6
37
  const TAG = 'binance-public';
7
38
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
8
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -26,8 +57,12 @@ export class BinancePublicApi {
26
57
  }
27
58
  this.exchange = new BinanceClass({
28
59
  enableRateLimit: true,
60
+ // ★ Load-bearing — see the file header. Without this ccxt.binance serves
61
+ // SPOT, which mispriced every perp read and hard-failed the futures-only
62
+ // 1000x contracts. Pinned by binance-public.test.ts.
63
+ options: { defaultType: 'future' },
29
64
  });
30
- logger.info(TAG, 'Binance public API initialized (no auth)');
65
+ logger.info(TAG, 'Binance public API initialized (no auth, USD-M futures)');
31
66
  }
32
67
  /** Fetch raw CCXT ticker (includes funding rate, OI, info). Returns null on error. */
33
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -146,16 +181,29 @@ export class BinancePublicApi {
146
181
  }
147
182
  /** Probe Binance USD-M FUTURES reachability from this host — the readiness
148
183
  * gate's core signal. Calls the futures-explicit implicit method so it hits
149
- * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
150
- * `api.binance.com`, since the public client doesn't set defaultType:'future').
184
+ * `fapi.binance.com` regardless of how the instance is configured. Keep it
185
+ * explicit even though the client now sets `defaultType: 'future'`: this
186
+ * probe must detect a futures-specific 451 geo-block, and it must not start
187
+ * silently probing spot if that option is ever changed.
151
188
  * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
152
189
  * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
153
190
  * noteBinanceError). Outcomes:
154
191
  * - 'reachable' clean response (driftMs = serverTime − localTime)
155
192
  * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
156
193
  * - 'unknown' the ban/weight gate paused us — NOT a host problem
157
- * - 'unreachable' network / DNS / timeout / other error */
194
+ * - 'stalled' THIS process was starved we learned nothing (issue #265)
195
+ * - 'unreachable' network / DNS / timeout / other error
196
+ *
197
+ * ★ Self-stall detection: an error is evidence about BINANCE only if it
198
+ * arrived on schedule. When the host starves the process the loop stops
199
+ * running, ccxt's own timeout lands minutes late, and the old code blamed the
200
+ * network — producing a confident "you are geo-blocked / check your firewall"
201
+ * banner while the agent was in fact trading normally. Past
202
+ * REACHABILITY_STALL_FACTOR× the request budget we report `stalled` and the
203
+ * reporter renders it `unknown`. A 451 still wins: the server ANSWERED, so
204
+ * that classification stands however late we noticed it. */
158
205
  async probeReachability() {
206
+ const startedAt = Date.now();
159
207
  try {
160
208
  assertNotBanned('reachabilityProbe');
161
209
  const r = await this.exchange.fapiPublicGetTime({});
@@ -173,8 +221,24 @@ export class BinancePublicApi {
173
221
  }
174
222
  const msg = err instanceof Error ? err.message : String(err);
175
223
  const geo = msg.includes('451');
176
- logger.warn(TAG, `probeReachability failed${geo ? ' (HTTP 451 geo-block)' : ''}: ${msg}`);
177
- return { outcome: geo ? 'geo_blocked' : 'unreachable', driftMs: null };
224
+ if (geo) {
225
+ logger.warn(TAG, `probeReachability failed (HTTP 451 geo-block): ${msg}`);
226
+ return { outcome: 'geo_blocked', driftMs: null };
227
+ }
228
+ const elapsedMs = Date.now() - startedAt;
229
+ if (elapsedMs > this.probeTimeoutMs() * REACHABILITY_STALL_FACTOR) {
230
+ logger.warn(TAG, `probeReachability inconclusive: this process was starved — a ${this.probeTimeoutMs()}ms probe took ${Math.round(elapsedMs / 1000)}s (${msg}). Reporting reachability as unknown, NOT as a Binance failure.`);
231
+ return { outcome: 'stalled', driftMs: null, stallMs: elapsedMs };
232
+ }
233
+ logger.warn(TAG, `probeReachability failed: ${msg}`);
234
+ return { outcome: 'unreachable', driftMs: null };
178
235
  }
179
236
  }
237
+ /** The request budget the stall yardstick is measured against. ccxt owns the
238
+ * actual timeout, so read it from the instance rather than hardcoding a
239
+ * second copy that could silently drift from it. */
240
+ probeTimeoutMs() {
241
+ const t = Number(this.exchange?.timeout);
242
+ return Number.isFinite(t) && t > 0 ? t : 10_000;
243
+ }
180
244
  }
@@ -0,0 +1,6 @@
1
+ export interface ProvenanceVerdict {
2
+ ok: boolean;
3
+ /** Agent-legible refusal — returned verbatim as the tool error. */
4
+ error?: string;
5
+ }
6
+ export declare function verifyOperatorProvenance(provided: unknown, configPath?: string): ProvenanceVerdict;
@@ -0,0 +1,50 @@
1
+ // Operator-provenance gate for the state-mutating operator tools
2
+ // (audit 2026-07-26 F12).
3
+ //
4
+ // "Operator-only. Never called by the agent." was PROSE in four tool
5
+ // descriptions — nothing enforced it, and `acknowledged:true` is
6
+ // caller-supplied, so a tool-capable agent could walk the PAPER→LIVE ladder,
7
+ // clear the exchange credentials, or disable the stop-loss requirement with
8
+ // one impulsive call. The dashboard path (browser → relay → skill bridge →
9
+ // gateway) now proves provenance: the bridge injects the rc_* connection
10
+ // token it already authenticates with, and the plugin compares it against
11
+ // its own copy. The chat redaction guards keep rc_* tokens OUT of the
12
+ // agent's context, so the agent cannot produce one conversationally.
13
+ //
14
+ // THREAT-MODEL HONESTY: the agent runs on the operator's own box and could
15
+ // read plugin-config with exec tools — this gate defends against one-call
16
+ // LLM impulsivity (the realistic failure mode), not a deliberately
17
+ // adversarial agent, which owns the box and could edit the config directly.
18
+ //
19
+ // Fail direction: OPEN when no connection token is configured (a pure-local
20
+ // dev box has no dashboard to inject provenance — same
21
+ // infrastructure-absent ⇒ allow direction as the entitlement gate);
22
+ // CLOSED on a missing or mismatched token when one IS configured.
23
+ // Read per call (no restart needed after a token rotation — F10 philosophy).
24
+ import { timingSafeEqual } from 'node:crypto';
25
+ import { readPluginConfig } from './plugin-config-io.js';
26
+ import { resolveIngestToken } from './user-data-stream-config.js';
27
+ const REFUSAL = 'operator_only: this tool changes safety-critical operator settings and accepts requests ' +
28
+ 'ONLY from the ReefClaw dashboard (operator provenance token missing or invalid). Do not ' +
29
+ 'retry and do not attempt to obtain the token — if this change is wanted, the OPERATOR ' +
30
+ 'makes it from the dashboard Settings panel.';
31
+ export function verifyOperatorProvenance(provided, configPath) {
32
+ let expected;
33
+ try {
34
+ expected = resolveIngestToken(readPluginConfig(configPath))?.trim() || undefined;
35
+ }
36
+ catch {
37
+ expected = undefined;
38
+ }
39
+ if (!expected)
40
+ return { ok: true }; // unconfigured box — nothing to prove against
41
+ const given = typeof provided === 'string' ? provided.trim() : '';
42
+ if (given.length === 0)
43
+ return { ok: false, error: REFUSAL };
44
+ const a = Buffer.from(given, 'utf8');
45
+ const b = Buffer.from(expected, 'utf8');
46
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
47
+ return { ok: false, error: REFUSAL };
48
+ }
49
+ return { ok: true };
50
+ }
@@ -46,8 +46,12 @@ export interface PluginConfigFile {
46
46
  hl?: {
47
47
  marketSlippagePct?: number;
48
48
  };
49
+ /** MICRO_LIVE per-order notional cap in quote-USD (USDT on Binance, USDC on
50
+ * Hyperliquid). Default 50 when the mode is MICRO_LIVE and no value is set.
51
+ * `sizeCapPercent` used to be accepted here but was NEVER enforced by any
52
+ * adapter (dead config implying protection it didn't provide) — it is no
53
+ * longer read; loadMicroLiveConfig warns when it is present. */
49
54
  microLive?: {
50
- sizeCapPercent?: number;
51
55
  maxPositionUSDT?: number;
52
56
  };
53
57
  /** Exchange-native bracket orders (STOP_MARKET + TAKE_PROFIT_MARKET).
@@ -202,6 +206,16 @@ export declare function defaultConfigPath(): string;
202
206
  * the override to the default (same read-at-build-time pattern as
203
207
  * loadBracketMode). */
204
208
  export declare function loadStopWatcherIntervalMs(path?: string): number | undefined;
209
+ /** Best-effort read of the operator's micro-live notional cap
210
+ * (`microLive.maxPositionUSDT`). Read at every adapter CONSTRUCTION — boot
211
+ * AND runtime reconnects — so a mode flip or credential save can't silently
212
+ * reset a raised OR lowered cap back to the $50 default (audit 2026-07-26
213
+ * F8; same read-at-build-time pattern as loadBracketMode /
214
+ * loadStopWatcherIntervalMs). Returns undefined when unset/garbage — the
215
+ * adapters then apply their own MICRO_LIVE default. */
216
+ export declare function loadMicroLiveConfig(path?: string): {
217
+ maxPositionUSDT?: number;
218
+ } | undefined;
205
219
  /** Read the config file. Returns `{}` if the file doesn't exist.
206
220
  * Throws if the file exists but is unreadable or not valid JSON — callers
207
221
  * should treat that as an abort signal, not silently overwrite. */
@@ -8,6 +8,7 @@
8
8
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
10
  import { dirname, join } from 'node:path';
11
+ import { logger } from '../logger.js';
11
12
  /** The connection the AGENT saves during onboarding lives in OpenClaw's own
12
13
  * config (`skills.entries.reefclaw.config` in ~/.openclaw/openclaw.json) —
13
14
  * the connector reads it there, and since the chat-install flow never writes
@@ -53,6 +54,29 @@ export function loadStopWatcherIntervalMs(path) {
53
54
  catch { /* best-effort — default applies */ }
54
55
  return undefined;
55
56
  }
57
+ /** Best-effort read of the operator's micro-live notional cap
58
+ * (`microLive.maxPositionUSDT`). Read at every adapter CONSTRUCTION — boot
59
+ * AND runtime reconnects — so a mode flip or credential save can't silently
60
+ * reset a raised OR lowered cap back to the $50 default (audit 2026-07-26
61
+ * F8; same read-at-build-time pattern as loadBracketMode /
62
+ * loadStopWatcherIntervalMs). Returns undefined when unset/garbage — the
63
+ * adapters then apply their own MICRO_LIVE default. */
64
+ export function loadMicroLiveConfig(path) {
65
+ try {
66
+ const ml = readPluginConfig(path).microLive;
67
+ if (!ml || typeof ml !== 'object')
68
+ return undefined;
69
+ if (ml.sizeCapPercent !== undefined) {
70
+ logger.warn('plugin-config', 'microLive.sizeCapPercent is set but has NEVER been enforced by any adapter — it has no effect. ' +
71
+ 'Remove it; microLive.maxPositionUSDT is the enforced per-order notional cap.');
72
+ }
73
+ const cap = ml.maxPositionUSDT;
74
+ if (typeof cap === 'number' && Number.isFinite(cap) && cap > 0)
75
+ return { maxPositionUSDT: cap };
76
+ }
77
+ catch { /* best-effort — adapter default applies */ }
78
+ return undefined;
79
+ }
56
80
  /** Read the config file. Returns `{}` if the file doesn't exist.
57
81
  * Throws if the file exists but is unreadable or not valid JSON — callers
58
82
  * should treat that as an abort signal, not silently overwrite. */