@reefclaw/openclaw-plugin 0.1.13 → 0.1.15
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/bridge/bridge.d.ts +20 -5
- package/bridge/bridge.js +29 -14
- package/bridge/config.js +6 -0
- package/bridge/gateway/gateway-config.d.ts +16 -5
- package/bridge/gateway/gateway-config.js +68 -12
- package/bridge/gateway/gateway-ws-client.d.ts +4 -1
- package/bridge/gateway/gateway-ws-client.js +41 -11
- package/bridge/gateway/poller.js +18 -8
- package/bridge/providers/emergency-commands.d.ts +9 -1
- package/bridge/providers/emergency-commands.js +38 -1
- package/bridge/providers/gateway.d.ts +51 -1
- package/bridge/providers/gateway.js +209 -22
- package/bridge/providers/onboarding-commands.d.ts +11 -0
- package/bridge/providers/onboarding-commands.js +5 -5
- package/bridge/providers/risk-calculator.d.ts +61 -2
- package/bridge/providers/risk-calculator.js +92 -20
- package/bridge/utils/skill-signing.js +8 -3
- package/ccxt/binance-public.d.ts +17 -5
- package/ccxt/binance-public.js +31 -3
- package/config/operator-provenance.d.ts +6 -0
- package/config/operator-provenance.js +50 -0
- package/config/plugin-config-io.d.ts +15 -1
- package/config/plugin-config-io.js +29 -0
- package/exchange-adapter.d.ts +13 -0
- package/index.js +230 -176
- package/ingest/event-loop-monitor.d.ts +22 -0
- package/ingest/event-loop-monitor.js +190 -0
- package/ingest/position-auto-capture.d.ts +5 -0
- package/ingest/position-auto-capture.js +14 -5
- package/ingest/readiness-reporter.d.ts +26 -6
- package/ingest/readiness-reporter.js +137 -9
- package/ingest/skill-version-reader.d.ts +16 -0
- package/ingest/skill-version-reader.js +64 -0
- package/live/approval-lifecycle.d.ts +30 -0
- package/live/approval-lifecycle.js +80 -0
- package/live/bracket-types.d.ts +9 -0
- package/live/live-adapter.d.ts +0 -1
- package/live/user-data-stream.js +10 -2
- package/onboarding/runtime.d.ts +34 -1
- package/onboarding/runtime.js +56 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -5
- package/risk/pre-trade-check.js +18 -5
- package/simulator/exchange-simulator.d.ts +45 -2
- package/simulator/exchange-simulator.js +96 -4
- package/simulator/types.d.ts +17 -0
- package/skills/reefclaw/SKILL.md +6 -11
- package/strategy/condition-registry.js +9 -2
- package/strategy/evaluator.d.ts +5 -0
- package/tools/attach-brackets.js +50 -1
- package/tools/cancel-all-orders.js +9 -1
- package/tools/create-order.js +18 -1
- package/tools/get-bracket-config.d.ts +21 -2
- package/tools/get-bracket-config.js +18 -2
- package/tools/set-trading-mode.js +6 -3
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
- package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
- package/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/venues/hyperliquid/hl-brackets.js +45 -13
- package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/venues/hyperliquid/hl-fill-ingest.js +88 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/venues/hyperliquid/hl-live-adapter.js +116 -7
- package/venues/hyperliquid/hl-public.d.ts +12 -5
- package/venues/hyperliquid/hl-public.js +24 -3
- package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- package/venues/hyperliquid/hl-user-stream.js +4 -1
- package/venues/registry.js +8 -7
- package/wave9/paper-admission-guard.d.ts +12 -1
- package/wave9/paper-admission-guard.js +12 -1
- package/scripts/assemble.mjs +0 -130
|
@@ -43,11 +43,62 @@ export function applyRegimeAdjustment(baseLimits, regime) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
// ---- Drawdown zone thresholds ----
|
|
46
|
+
/** Fallback zone boundaries, used only until the tenant's trading params load.
|
|
47
|
+
* The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
|
|
48
|
+
* trading params — the same numbers the plugin's pre-trade gate rejects on.
|
|
49
|
+
* Keeping a second hardcoded ladder here is how the skill ended up
|
|
50
|
+
* auto-flattening at -2.5% on an account configured to -4%. */
|
|
46
51
|
export const DRAWDOWN_ZONE_THRESHOLDS = {
|
|
47
52
|
YELLOW: -0.01, // -1%
|
|
48
53
|
ORANGE: -0.02, // -2%
|
|
49
54
|
RED: -0.025, // -2.5%
|
|
50
55
|
};
|
|
56
|
+
// ---- Hardcoded bounds on tenant-supplied risk params ----
|
|
57
|
+
//
|
|
58
|
+
// CLAUDE.md load-bearing rule: central config may move knobs only WITHIN
|
|
59
|
+
// hardcoded bounds — it must never be able to disable a safety mechanism.
|
|
60
|
+
// The RED threshold drives the auto-flatten, so without a floor a single
|
|
61
|
+
// careless dashboard edit (drawdownRed = -0.5) would silently switch the
|
|
62
|
+
// auto-flatten off. Out-of-band values are REJECTED (keep last-good), not
|
|
63
|
+
// clamped — we never invent a threshold the operator didn't set.
|
|
64
|
+
/** The auto-flatten trigger can never be configured looser than −10%. */
|
|
65
|
+
export const AUTO_FLATTEN_RED_FLOOR = -0.10;
|
|
66
|
+
/** Sanity bands for tenant limit fields. Values outside → field ignored,
|
|
67
|
+
* previous value kept. Wide on purpose: these reject nonsense, not policy. */
|
|
68
|
+
export const TENANT_LIMIT_BOUNDS = {
|
|
69
|
+
maxPositionSize: { min: 1, max: 1e9 },
|
|
70
|
+
maxOpenPositions: { min: 1, max: 100 },
|
|
71
|
+
maxGrossExposure: { min: 0.01, max: 20 },
|
|
72
|
+
maxPerTradeLoss: { min: 0.01, max: 1e7 }, // magnitude; sign applied by caller
|
|
73
|
+
};
|
|
74
|
+
/** Finite number within [min, max], else null. */
|
|
75
|
+
export function boundedNum(v, min, max) {
|
|
76
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
77
|
+
return null;
|
|
78
|
+
return v >= min && v <= max ? v : null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
|
|
82
|
+
* a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
|
|
83
|
+
* losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
|
|
84
|
+
* put the book straight into RED and auto-flatten it); RED no looser than
|
|
85
|
+
* AUTO_FLATTEN_RED_FLOOR.
|
|
86
|
+
*/
|
|
87
|
+
export function validateDrawdownLadder(t) {
|
|
88
|
+
for (const [k, v] of Object.entries(t)) {
|
|
89
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
90
|
+
return `${k} is not a finite number`;
|
|
91
|
+
}
|
|
92
|
+
if (t.YELLOW > 0)
|
|
93
|
+
return `YELLOW ${t.YELLOW} must be ≤ 0`;
|
|
94
|
+
if (!(t.YELLOW > t.ORANGE && t.ORANGE > t.RED)) {
|
|
95
|
+
return `not monotonic (need YELLOW > ORANGE > RED, got ${t.YELLOW} / ${t.ORANGE} / ${t.RED})`;
|
|
96
|
+
}
|
|
97
|
+
if (t.RED < AUTO_FLATTEN_RED_FLOOR) {
|
|
98
|
+
return `RED ${t.RED} is looser than the hardcoded auto-flatten floor ${AUTO_FLATTEN_RED_FLOOR}`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
51
102
|
// ---- Rate tracking window ----
|
|
52
103
|
const RATE_WINDOW_MS = 60_000; // 1 minute
|
|
53
104
|
// ---- Volatility + drawdown pure functions ----
|
|
@@ -73,12 +124,12 @@ export function applyVolatilityAdjustment(baseLimits, volFactor) {
|
|
|
73
124
|
};
|
|
74
125
|
}
|
|
75
126
|
/** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
|
|
76
|
-
export function getDrawdownZone(drawdownRatio) {
|
|
77
|
-
if (drawdownRatio <=
|
|
127
|
+
export function getDrawdownZone(drawdownRatio, thresholds = DRAWDOWN_ZONE_THRESHOLDS) {
|
|
128
|
+
if (drawdownRatio <= thresholds.RED)
|
|
78
129
|
return 'RED';
|
|
79
|
-
if (drawdownRatio <=
|
|
130
|
+
if (drawdownRatio <= thresholds.ORANGE)
|
|
80
131
|
return 'ORANGE';
|
|
81
|
-
if (drawdownRatio <=
|
|
132
|
+
if (drawdownRatio <= thresholds.YELLOW)
|
|
82
133
|
return 'YELLOW';
|
|
83
134
|
return 'GREEN';
|
|
84
135
|
}
|
|
@@ -143,10 +194,22 @@ export function countRecentEvents(timestamps) {
|
|
|
143
194
|
}
|
|
144
195
|
/**
|
|
145
196
|
* Detect risk limit breaches from current metrics.
|
|
197
|
+
*
|
|
198
|
+
* ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
|
|
199
|
+
* tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
|
|
200
|
+
* orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
|
|
201
|
+
* the dashboard must mean "the agent is being blocked right now".
|
|
202
|
+
*
|
|
203
|
+
* `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
|
|
204
|
+
* live only in this file — no enforcer applies them — so they may raise a
|
|
205
|
+
* WARNING (amber, indication) but must never produce a CRITICAL. Conflating
|
|
206
|
+
* the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
|
|
207
|
+
* live book whose 1.5x enforced limit was never even approached.
|
|
146
208
|
*/
|
|
147
|
-
export function detectBreaches(metrics, limits) {
|
|
209
|
+
export function detectBreaches(metrics, limits, advisoryLimits) {
|
|
148
210
|
const breaches = [];
|
|
149
211
|
const now = new Date().toISOString();
|
|
212
|
+
const advisory = advisoryLimits ?? limits;
|
|
150
213
|
// Gross exposure
|
|
151
214
|
if (metrics.grossExposure > limits.position.maxGrossExposure) {
|
|
152
215
|
breaches.push({
|
|
@@ -158,10 +221,11 @@ export function detectBreaches(metrics, limits) {
|
|
|
158
221
|
timestamp: now,
|
|
159
222
|
});
|
|
160
223
|
}
|
|
161
|
-
else if (metrics.grossExposure >
|
|
224
|
+
else if (metrics.grossExposure > advisory.position.maxGrossExposure ||
|
|
225
|
+
metrics.grossExposure > limits.position.maxGrossExposure * 0.8) {
|
|
162
226
|
breaches.push({
|
|
163
227
|
metric: 'grossExposure',
|
|
164
|
-
limit: limits.position.maxGrossExposure,
|
|
228
|
+
limit: Math.min(advisory.position.maxGrossExposure, limits.position.maxGrossExposure),
|
|
165
229
|
current: metrics.grossExposure,
|
|
166
230
|
level: 'WARNING',
|
|
167
231
|
action: 'ALERT',
|
|
@@ -179,10 +243,11 @@ export function detectBreaches(metrics, limits) {
|
|
|
179
243
|
timestamp: now,
|
|
180
244
|
});
|
|
181
245
|
}
|
|
182
|
-
else if (Math.abs(metrics.netExposure) >
|
|
246
|
+
else if (Math.abs(metrics.netExposure) > advisory.position.maxNetExposure ||
|
|
247
|
+
Math.abs(metrics.netExposure) > limits.position.maxNetExposure * 0.8) {
|
|
183
248
|
breaches.push({
|
|
184
249
|
metric: 'netExposure',
|
|
185
|
-
limit: limits.position.maxNetExposure,
|
|
250
|
+
limit: Math.min(advisory.position.maxNetExposure, limits.position.maxNetExposure),
|
|
186
251
|
current: metrics.netExposure,
|
|
187
252
|
level: 'WARNING',
|
|
188
253
|
action: 'ALERT',
|
|
@@ -270,8 +335,12 @@ export function computeRiskMetrics(state) {
|
|
|
270
335
|
// Prune and count rate events (non-mutating)
|
|
271
336
|
const orderResult = countRecentEvents(state.orderTimestamps);
|
|
272
337
|
const cancelResult = countRecentEvents(state.cancelTimestamps);
|
|
338
|
+
// Enforced limits: the tenant's configured trading params when they've been
|
|
339
|
+
// fetched, else the fallback constants. These are what the plugin's
|
|
340
|
+
// pre-trade gate rejects on, so they — and only they — drive CRITICAL.
|
|
341
|
+
const enforcedLimits = state.baseLimits ?? DEFAULT_RISK_LIMITS;
|
|
273
342
|
// Volatility adjustment (when ATR data is available)
|
|
274
|
-
let adjustedLimits =
|
|
343
|
+
let adjustedLimits = enforcedLimits;
|
|
275
344
|
let effectiveLimits;
|
|
276
345
|
let volatilityInfo;
|
|
277
346
|
let volFactor = 1.0;
|
|
@@ -279,7 +348,7 @@ export function computeRiskMetrics(state) {
|
|
|
279
348
|
volFactor = computeVolFactor(state.atrData);
|
|
280
349
|
const adjusted = volFactor > 1.0;
|
|
281
350
|
if (adjusted) {
|
|
282
|
-
adjustedLimits = applyVolatilityAdjustment(
|
|
351
|
+
adjustedLimits = applyVolatilityAdjustment(enforcedLimits, volFactor);
|
|
283
352
|
}
|
|
284
353
|
volatilityInfo = {
|
|
285
354
|
atr14: state.atrData.currentAtr,
|
|
@@ -305,14 +374,17 @@ export function computeRiskMetrics(state) {
|
|
|
305
374
|
};
|
|
306
375
|
}
|
|
307
376
|
}
|
|
308
|
-
if (adjustedLimits !==
|
|
377
|
+
if (adjustedLimits !== enforcedLimits) {
|
|
309
378
|
effectiveLimits = adjustedLimits;
|
|
310
379
|
}
|
|
311
|
-
// Drawdown zone
|
|
312
|
-
|
|
380
|
+
// Drawdown zone — boundaries come from the tenant's trading params so the
|
|
381
|
+
// skill's RED-zone auto-flatten fires at the operator's configured level,
|
|
382
|
+
// not a second hardcoded ladder that can be stricter than the plugin's.
|
|
383
|
+
const zoneThresholds = state.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
|
|
384
|
+
const drawdownZone = getDrawdownZone(dailyDrawdown, zoneThresholds);
|
|
313
385
|
const drawdownZoneMessage = getDrawdownZoneMessage(drawdownZone, dailyDrawdown);
|
|
314
|
-
//
|
|
315
|
-
|
|
386
|
+
// Breaches: CRITICAL against the ENFORCED limits, WARNING against the
|
|
387
|
+
// vol/regime-tightened advisory view (see detectBreaches).
|
|
316
388
|
const dailyLoss = state.realizedPnlToday ?? 0;
|
|
317
389
|
const breaches = detectBreaches({
|
|
318
390
|
grossExposure: grossRatio,
|
|
@@ -321,12 +393,12 @@ export function computeRiskMetrics(state) {
|
|
|
321
393
|
dailyLoss,
|
|
322
394
|
ordersPerMinute: orderResult.count,
|
|
323
395
|
cancelsPerMinute: cancelResult.count,
|
|
324
|
-
},
|
|
396
|
+
}, enforcedLimits, effectiveLimits);
|
|
325
397
|
// Add zone-specific breaches
|
|
326
398
|
if (drawdownZone === 'ORANGE' || drawdownZone === 'RED') {
|
|
327
399
|
breaches.push({
|
|
328
400
|
metric: 'drawdownZone',
|
|
329
|
-
limit: drawdownZone === 'RED' ?
|
|
401
|
+
limit: drawdownZone === 'RED' ? zoneThresholds.RED : zoneThresholds.ORANGE,
|
|
330
402
|
current: dailyDrawdown,
|
|
331
403
|
level: 'CRITICAL',
|
|
332
404
|
action: drawdownZone === 'RED' ? 'AUTO_PAUSE' : 'REJECT_ORDER',
|
|
@@ -336,7 +408,7 @@ export function computeRiskMetrics(state) {
|
|
|
336
408
|
else if (drawdownZone === 'YELLOW') {
|
|
337
409
|
breaches.push({
|
|
338
410
|
metric: 'drawdownZone',
|
|
339
|
-
limit:
|
|
411
|
+
limit: zoneThresholds.YELLOW,
|
|
340
412
|
current: dailyDrawdown,
|
|
341
413
|
level: 'WARNING',
|
|
342
414
|
action: 'ALERT',
|
|
@@ -345,7 +417,7 @@ export function computeRiskMetrics(state) {
|
|
|
345
417
|
}
|
|
346
418
|
return {
|
|
347
419
|
metrics: {
|
|
348
|
-
limits:
|
|
420
|
+
limits: enforcedLimits,
|
|
349
421
|
utilization: {
|
|
350
422
|
currentPositionSize: positionSizes,
|
|
351
423
|
openPositionCount: state.positions.filter((p) => computePositionNotional(p, state.lastTickerPrice) !== 0).length,
|
|
@@ -14,9 +14,14 @@
|
|
|
14
14
|
// (gitignored — keep it offline; NEVER on the relay or any server)
|
|
15
15
|
// 4. rebuild + redeploy the skill (deploy-skillmd-ota.py is already wired to sign)
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// —
|
|
17
|
+
// ENFORCEMENT IS CURRENTLY DORMANT: SKILL_OTA_REQUIRE_SIGNATURE defaults OFF
|
|
18
|
+
// and no key is pinned (C1 is ON HOLD pending the agent-update-mechanism
|
|
19
|
+
// decision — see signatureRequired() below). While dormant, OTA updates apply
|
|
20
|
+
// unsigned exactly as before this module existed; the authenticated webapp
|
|
21
|
+
// pull channel (rc_ token) remains the trust boundary. When the flag is turned
|
|
22
|
+
// ON: a pinned key verifies every update, and flag-on-without-a-key FAILS
|
|
23
|
+
// CLOSED (every push rejected). Normal trading is unaffected either way —
|
|
24
|
+
// only the OTA-update path gates on this.
|
|
20
25
|
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
21
26
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
22
27
|
import { join } from 'node:path';
|
package/ccxt/binance-public.d.ts
CHANGED
|
@@ -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';
|
|
@@ -28,9 +29,20 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
|
|
|
28
29
|
* - 'reachable' clean response (driftMs = serverTime − localTime)
|
|
29
30
|
* - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
|
|
30
31
|
* - 'unknown' the ban/weight gate paused us — NOT a host problem
|
|
31
|
-
* - '
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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;
|
|
36
48
|
}
|
package/ccxt/binance-public.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { createRequire } from 'node:module';
|
|
34
34
|
import { logger } from '../logger.js';
|
|
35
35
|
import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
|
|
36
|
+
import { REACHABILITY_STALL_FACTOR } from '@reefclaw/shared';
|
|
36
37
|
const TAG = 'binance-public';
|
|
37
38
|
// Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
|
|
38
39
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -190,8 +191,19 @@ export class BinancePublicApi {
|
|
|
190
191
|
* - 'reachable' clean response (driftMs = serverTime − localTime)
|
|
191
192
|
* - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
|
|
192
193
|
* - 'unknown' the ban/weight gate paused us — NOT a host problem
|
|
193
|
-
* - '
|
|
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. */
|
|
194
205
|
async probeReachability() {
|
|
206
|
+
const startedAt = Date.now();
|
|
195
207
|
try {
|
|
196
208
|
assertNotBanned('reachabilityProbe');
|
|
197
209
|
const r = await this.exchange.fapiPublicGetTime({});
|
|
@@ -209,8 +221,24 @@ export class BinancePublicApi {
|
|
|
209
221
|
}
|
|
210
222
|
const msg = err instanceof Error ? err.message : String(err);
|
|
211
223
|
const geo = msg.includes('451');
|
|
212
|
-
|
|
213
|
-
|
|
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 };
|
|
214
235
|
}
|
|
215
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
|
+
}
|
|
216
244
|
}
|
|
@@ -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. */
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
// sets credentials or switches trading mode from the webapp. Writes must be
|
|
6
6
|
// crash-safe (no half-written JSON) and must preserve keys we don't understand
|
|
7
7
|
// (forwards compatibility — other tools may add fields we don't know about).
|
|
8
|
+
//
|
|
9
|
+
// Write authorization: every mutation path into this module is an
|
|
10
|
+
// operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
|
|
11
|
+
// audit F12) — the agent cannot reach these writes conversationally. The file
|
|
12
|
+
// is the plugin's OWN config store; nothing here touches OpenClaw's config.
|
|
8
13
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
9
14
|
import { homedir } from 'node:os';
|
|
10
15
|
import { dirname, join } from 'node:path';
|
|
16
|
+
import { logger } from '../logger.js';
|
|
11
17
|
/** The connection the AGENT saves during onboarding lives in OpenClaw's own
|
|
12
18
|
* config (`skills.entries.reefclaw.config` in ~/.openclaw/openclaw.json) —
|
|
13
19
|
* the connector reads it there, and since the chat-install flow never writes
|
|
@@ -53,6 +59,29 @@ export function loadStopWatcherIntervalMs(path) {
|
|
|
53
59
|
catch { /* best-effort — default applies */ }
|
|
54
60
|
return undefined;
|
|
55
61
|
}
|
|
62
|
+
/** Best-effort read of the operator's micro-live notional cap
|
|
63
|
+
* (`microLive.maxPositionUSDT`). Read at every adapter CONSTRUCTION — boot
|
|
64
|
+
* AND runtime reconnects — so a mode flip or credential save can't silently
|
|
65
|
+
* reset a raised OR lowered cap back to the $50 default (audit 2026-07-26
|
|
66
|
+
* F8; same read-at-build-time pattern as loadBracketMode /
|
|
67
|
+
* loadStopWatcherIntervalMs). Returns undefined when unset/garbage — the
|
|
68
|
+
* adapters then apply their own MICRO_LIVE default. */
|
|
69
|
+
export function loadMicroLiveConfig(path) {
|
|
70
|
+
try {
|
|
71
|
+
const ml = readPluginConfig(path).microLive;
|
|
72
|
+
if (!ml || typeof ml !== 'object')
|
|
73
|
+
return undefined;
|
|
74
|
+
if (ml.sizeCapPercent !== undefined) {
|
|
75
|
+
logger.warn('plugin-config', 'microLive.sizeCapPercent is set but has NEVER been enforced by any adapter — it has no effect. ' +
|
|
76
|
+
'Remove it; microLive.maxPositionUSDT is the enforced per-order notional cap.');
|
|
77
|
+
}
|
|
78
|
+
const cap = ml.maxPositionUSDT;
|
|
79
|
+
if (typeof cap === 'number' && Number.isFinite(cap) && cap > 0)
|
|
80
|
+
return { maxPositionUSDT: cap };
|
|
81
|
+
}
|
|
82
|
+
catch { /* best-effort — adapter default applies */ }
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
56
85
|
/** Read the config file. Returns `{}` if the file doesn't exist.
|
|
57
86
|
* Throws if the file exists but is unreadable or not valid JSON — callers
|
|
58
87
|
* should treat that as an abort signal, not silently overwrite. */
|
package/exchange-adapter.d.ts
CHANGED
|
@@ -28,6 +28,19 @@ export interface OrderOptions {
|
|
|
28
28
|
* Only create_order is blocked when readiness !== 'READY'.
|
|
29
29
|
*/
|
|
30
30
|
export interface IExchangeAdapter {
|
|
31
|
+
/**
|
|
32
|
+
* ★ Venue capability: this adapter's exchange-side protective legs ARE the
|
|
33
|
+
* safety floor — there is no watcher fallback and no `brackets.mode=off`
|
|
34
|
+
* arm, so brackets are attached unconditionally (Hyperliquid live).
|
|
35
|
+
*
|
|
36
|
+
* Read it wherever `brackets.mode` is consulted. That flag is a
|
|
37
|
+
* BINANCE-ONLY lifecycle knob (it is only ever passed to `LiveAdapter`), so
|
|
38
|
+
* treating it as the global answer made an HL rig report "Brackets off" on
|
|
39
|
+
* the dashboard AND — worse — skipped the mandatory-stop pre-trade gate,
|
|
40
|
+
* because that gate was wired behind `bracketsEnabled(loadBracketMode())`.
|
|
41
|
+
* Omitted/false on Binance + paper: byte-identical to the old behaviour.
|
|
42
|
+
*/
|
|
43
|
+
readonly bracketsAlwaysEnforced?: boolean;
|
|
31
44
|
createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, options?: OrderOptions): Promise<CcxtOrder>;
|
|
32
45
|
cancelOrder(orderId: string, symbol?: string): Promise<CcxtOrder>;
|
|
33
46
|
cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;
|