@reefclaw/openclaw-plugin 0.1.22 → 0.1.24
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.js +110 -5
- package/bridge/connector.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +4 -0
- package/bridge/provider.d.ts +36 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +37 -2
- package/bridge/providers/gateway.js +164 -9
- package/bridge/providers/mock.js +1 -0
- package/bridge/providers/onboarding-commands.d.ts +12 -1
- package/bridge/providers/onboarding-commands.js +25 -0
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +7 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/config/tool-gate.js +1 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +73 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/openclaw.plugin.json +2 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +18 -0
- package/tools/hl-submit-agent-approval.d.ts +27 -0
- package/tools/hl-submit-agent-approval.js +140 -0
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// hl_submit_agent_approval — operator-only plugin tool.
|
|
2
|
+
//
|
|
3
|
+
// Submits the operator's WALLET-SIGNED `approveAgent` action to Hyperliquid
|
|
4
|
+
// from THIS BOX, instead of from the dashboard's browser.
|
|
5
|
+
//
|
|
6
|
+
// ★ Why the box and not the browser (real failure, 2026-08-03): the guided
|
|
7
|
+
// flow POSTed to api.hyperliquid*.xyz directly from the dashboard page and an
|
|
8
|
+
// operator hit "Failed to fetch" twice. The endpoint was fine (CORS is `*`,
|
|
9
|
+
// POSTs answer normally) and the box reached it in 0.49s — the request was
|
|
10
|
+
// blocked INSIDE the browser (ad-blocker / shield / DNS policy; crypto API
|
|
11
|
+
// domains are routinely blocked). Making a required onboarding step depend on
|
|
12
|
+
// the operator's browser extensions is the wrong architecture: the box is
|
|
13
|
+
// already the Hyperliquid client (every read and every future order goes
|
|
14
|
+
// through it), so it submits. The browser's only irreplaceable job is holding
|
|
15
|
+
// the wallet and producing the signature. ReefClaw's servers stay out of the
|
|
16
|
+
// path — the signed action travels over the existing relay to the user's own
|
|
17
|
+
// machine.
|
|
18
|
+
//
|
|
19
|
+
// ★ SECURITY — this is deliberately NOT a generic signed-action relay.
|
|
20
|
+
// A tool that forwarded any {action, signature} pair to /exchange would be a
|
|
21
|
+
// capability escalation: HL's withdraw / usdSend / usdClassTransfer are all
|
|
22
|
+
// user-signed actions in the same envelope shape. If a signature for one of
|
|
23
|
+
// those ever existed, a generic relay would happily submit it. So:
|
|
24
|
+
// 1. `action.type` MUST be exactly 'approveAgent' — nothing else is accepted.
|
|
25
|
+
// 2. `action.agentAddress` MUST be the address THIS box's provisioned agent
|
|
26
|
+
// key controls, so the tool can only ever authorize our own wallet.
|
|
27
|
+
// 3. The endpoint is chosen from the SIGNED action's `hyperliquidChain`, so
|
|
28
|
+
// it cannot be pointed at a different network than the one signed for.
|
|
29
|
+
// The signature itself is produced and validated by Hyperliquid; we never
|
|
30
|
+
// touch key material here (the agent key is only used to DERIVE its public
|
|
31
|
+
// address for check 2).
|
|
32
|
+
import { readPluginConfig } from '../config/plugin-config-io.js';
|
|
33
|
+
import { deriveAddressFromPrivateKey, isHexAddress, sameAddress } from '../venues/hyperliquid/hl-agent-wallet.js';
|
|
34
|
+
import { hlApiBase } from '../venues/hyperliquid/hl-preflight.js';
|
|
35
|
+
import { logger } from '../logger.js';
|
|
36
|
+
const TAG = 'hl-submit-agent-approval';
|
|
37
|
+
function fail(message, extra = {}) {
|
|
38
|
+
return { ok: false, message, ...extra };
|
|
39
|
+
}
|
|
40
|
+
export async function hlSubmitAgentApprovalTool(args, deps = {}) {
|
|
41
|
+
if (args?.probe === true) {
|
|
42
|
+
return { ok: false, message: 'probe' };
|
|
43
|
+
}
|
|
44
|
+
const action = args?.action;
|
|
45
|
+
if (!action || typeof action !== 'object') {
|
|
46
|
+
return fail('action is required (the approveAgent object the wallet signed)');
|
|
47
|
+
}
|
|
48
|
+
// 1. Only ever approveAgent — never a generic relay (see header).
|
|
49
|
+
if (action.type !== 'approveAgent') {
|
|
50
|
+
logger.warn(TAG, `refused non-approveAgent action type: ${String(action.type)}`);
|
|
51
|
+
return fail(`Refused: this tool only submits 'approveAgent' actions, not '${String(action.type)}'.`);
|
|
52
|
+
}
|
|
53
|
+
const chain = action.hyperliquidChain;
|
|
54
|
+
if (chain !== 'Mainnet' && chain !== 'Testnet') {
|
|
55
|
+
return fail("action.hyperliquidChain must be 'Mainnet' or 'Testnet'");
|
|
56
|
+
}
|
|
57
|
+
const agentAddress = action.agentAddress;
|
|
58
|
+
if (!isHexAddress(agentAddress)) {
|
|
59
|
+
return fail('action.agentAddress must be a 0x… address', { chain });
|
|
60
|
+
}
|
|
61
|
+
const sig = args?.signature;
|
|
62
|
+
const r = sig?.r;
|
|
63
|
+
const s = sig?.s;
|
|
64
|
+
const v = sig?.v;
|
|
65
|
+
if (typeof r !== 'string' || typeof s !== 'string' || typeof v !== 'number') {
|
|
66
|
+
return fail('signature must be { r: string, s: string, v: number }', { chain });
|
|
67
|
+
}
|
|
68
|
+
const nonce = args?.nonce;
|
|
69
|
+
// User-signed actions carry ONE nonce — the outer value must match the one
|
|
70
|
+
// inside the signed payload, or HL verifies a different message than we send.
|
|
71
|
+
if (typeof nonce !== 'number' || nonce !== action.nonce) {
|
|
72
|
+
return fail('nonce must be a number equal to action.nonce', { chain });
|
|
73
|
+
}
|
|
74
|
+
// 2. The agent being approved must be OUR provisioned wallet.
|
|
75
|
+
let storedKey;
|
|
76
|
+
try {
|
|
77
|
+
const cfg = readPluginConfig(deps.configPath);
|
|
78
|
+
if (typeof cfg.exchange?.agentPrivateKey === 'string')
|
|
79
|
+
storedKey = cfg.exchange.agentPrivateKey;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// fall through to the not-provisioned error
|
|
83
|
+
}
|
|
84
|
+
if (!storedKey) {
|
|
85
|
+
return fail('No Hyperliquid agent wallet is provisioned on this machine — run the guided setup first.', { chain });
|
|
86
|
+
}
|
|
87
|
+
const derived = await deriveAddressFromPrivateKey(storedKey);
|
|
88
|
+
if (!derived.ok) {
|
|
89
|
+
return fail('Could not derive this machine’s agent wallet address — cannot verify what is being approved.', { chain });
|
|
90
|
+
}
|
|
91
|
+
if (!sameAddress(derived.address, agentAddress)) {
|
|
92
|
+
logger.warn(TAG, 'refused: action.agentAddress is not this box’s provisioned agent');
|
|
93
|
+
return fail('Refused: the approval targets a different agent wallet than the one provisioned on this machine.', { chain, agentAddress: derived.address });
|
|
94
|
+
}
|
|
95
|
+
// 3. Submit — endpoint from the SIGNED chain, never from local config.
|
|
96
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
97
|
+
const url = `${hlApiBase(chain === 'Testnet')}/exchange`;
|
|
98
|
+
logger.info(TAG, `submitting approveAgent for ${agentAddress} to ${chain}`);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetchImpl(url, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ action, nonce, signature: { r, s, v } }),
|
|
104
|
+
signal: AbortSignal.timeout(20_000),
|
|
105
|
+
});
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
let body = null;
|
|
108
|
+
try {
|
|
109
|
+
body = JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// non-JSON — surfaced verbatim below
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok || body?.status !== 'ok') {
|
|
115
|
+
const detail = typeof body?.response === 'string' ? body.response : text.slice(0, 300) || `HTTP ${res.status}`;
|
|
116
|
+
logger.warn(TAG, `Hyperliquid rejected the approval: ${detail}`);
|
|
117
|
+
return fail(`Hyperliquid rejected the approval: ${detail}`, {
|
|
118
|
+
hlStatus: body?.status,
|
|
119
|
+
chain,
|
|
120
|
+
agentAddress: derived.address,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
logger.info(TAG, `approveAgent accepted by Hyperliquid (${chain})`);
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
message: `Approval submitted and accepted by Hyperliquid (${chain}).`,
|
|
127
|
+
hlStatus: 'ok',
|
|
128
|
+
chain,
|
|
129
|
+
agentAddress: derived.address,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
134
|
+
logger.error(TAG, `submit failed: ${msg}`);
|
|
135
|
+
return fail(`Could not reach Hyperliquid from this machine: ${msg}`, {
|
|
136
|
+
chain,
|
|
137
|
+
agentAddress: derived.address,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
package/tools/intel-api.d.ts
CHANGED
|
@@ -26,7 +26,16 @@ export interface FetchOptions {
|
|
|
26
26
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
27
27
|
body?: unknown;
|
|
28
28
|
timeoutMs?: number;
|
|
29
|
+
/** Opt-in TTL cache for GET reads. The same intel endpoints get hit several
|
|
30
|
+
* times per heartbeat by different callers (agent tool + microstructure
|
|
31
|
+
* assembler + repeated agent calls) — a short shared TTL collapses those
|
|
32
|
+
* into one round-trip. Never set on polling reads that must observe fresh
|
|
33
|
+
* server state (e.g. backtest status). */
|
|
34
|
+
cacheTtlMs?: number;
|
|
29
35
|
}
|
|
36
|
+
/** Test support: drop every cached GET response (the cache is module-level,
|
|
37
|
+
* so suites that stub fetch with per-test responses must clear it). */
|
|
38
|
+
export declare function clearIntelGetCache(): void;
|
|
30
39
|
/** Encode a value for safe use in a URL path segment */
|
|
31
40
|
export declare const enc: typeof encodeURIComponent;
|
|
32
41
|
export declare function fetchIntelApi(path: string, deps: IntelApiDeps, options?: FetchOptions): Promise<Record<string, unknown> | {
|
package/tools/intel-api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Shared helper for Intelligence API calls.
|
|
2
2
|
// All intelligence tools use Bearer token auth against intel.reefclaw.com.
|
|
3
|
+
import { keepAliveFetch } from '../http/keepalive-fetch.js';
|
|
3
4
|
import { HL_INTEL_PREFIX, fromIntelSymbol, toIntelSymbol } from '../venues/symbols.js';
|
|
4
5
|
// ─── Venue-aware intel-symbol mapping (plan §5.3: the agent keeps using
|
|
5
6
|
// canonical symbols like BTC/USDC; these helpers translate at the intel
|
|
@@ -67,6 +68,13 @@ export function intelSymbolOnVenue(deps, intelSymbol) {
|
|
|
67
68
|
const isHl = intelSymbol.startsWith(HL_INTEL_PREFIX);
|
|
68
69
|
return (deps.venue ?? 'binance') === 'hyperliquid' ? isHl : !isHl;
|
|
69
70
|
}
|
|
71
|
+
const getCache = new Map();
|
|
72
|
+
const GET_CACHE_MAX_ENTRIES = 256;
|
|
73
|
+
/** Test support: drop every cached GET response (the cache is module-level,
|
|
74
|
+
* so suites that stub fetch with per-test responses must clear it). */
|
|
75
|
+
export function clearIntelGetCache() {
|
|
76
|
+
getCache.clear();
|
|
77
|
+
}
|
|
70
78
|
/** Encode a value for safe use in a URL path segment */
|
|
71
79
|
export const enc = encodeURIComponent;
|
|
72
80
|
export async function fetchIntelApi(path, deps, options) {
|
|
@@ -78,6 +86,29 @@ export async function fetchIntelApi(path, deps, options) {
|
|
|
78
86
|
return { error: 'No intelligence URL configured. Set intelligenceUrl in openclaw.json plugin config.' };
|
|
79
87
|
}
|
|
80
88
|
const url = `${intelligenceUrl}${path}`;
|
|
89
|
+
const ttl = options?.cacheTtlMs ?? 0;
|
|
90
|
+
if (ttl > 0 && (options?.method ?? 'GET') === 'GET') {
|
|
91
|
+
const key = `${connectionToken}:${url}`;
|
|
92
|
+
const hit = getCache.get(key);
|
|
93
|
+
if (hit && Date.now() - hit.at < ttl) {
|
|
94
|
+
// structuredClone: callers must never alias each other's response object.
|
|
95
|
+
return hit.value.then((v) => structuredClone(v));
|
|
96
|
+
}
|
|
97
|
+
const value = fetchIntelApiUncached(url, connectionToken, options);
|
|
98
|
+
getCache.set(key, { at: Date.now(), value });
|
|
99
|
+
// Failures don't stick for the TTL — next caller retries fresh.
|
|
100
|
+
void value.then((v) => { if (v && typeof v === 'object' && 'error' in v)
|
|
101
|
+
getCache.delete(key); }, () => getCache.delete(key));
|
|
102
|
+
if (getCache.size > GET_CACHE_MAX_ENTRIES) {
|
|
103
|
+
const oldest = getCache.keys().next().value;
|
|
104
|
+
if (oldest !== undefined)
|
|
105
|
+
getCache.delete(oldest);
|
|
106
|
+
}
|
|
107
|
+
return value.then((v) => structuredClone(v));
|
|
108
|
+
}
|
|
109
|
+
return fetchIntelApiUncached(url, connectionToken, options);
|
|
110
|
+
}
|
|
111
|
+
async function fetchIntelApiUncached(url, connectionToken, options) {
|
|
81
112
|
const headers = { Authorization: `Bearer ${connectionToken}` };
|
|
82
113
|
const fetchInit = {
|
|
83
114
|
method: options?.method ?? 'GET',
|
|
@@ -89,7 +120,7 @@ export async function fetchIntelApi(path, deps, options) {
|
|
|
89
120
|
fetchInit.body = JSON.stringify(options.body);
|
|
90
121
|
}
|
|
91
122
|
try {
|
|
92
|
-
const res = await
|
|
123
|
+
const res = await keepAliveFetch(url, fetchInit);
|
|
93
124
|
if (res.status === 401) {
|
|
94
125
|
return { error: 'Invalid or expired ReefClaw connection token.' };
|
|
95
126
|
}
|
|
@@ -94,7 +94,6 @@ export async function recordPositionReviewsTool(args, deps) {
|
|
|
94
94
|
// not load-bearing (each row carries its own reviewAt).
|
|
95
95
|
let filed = 0;
|
|
96
96
|
if (deps.decisionsClient && deps.userId) {
|
|
97
|
-
const promises = [];
|
|
98
97
|
for (const r of reviews) {
|
|
99
98
|
const stateEntry = deps.stateStore?.get(r.symbol);
|
|
100
99
|
const positionId = stateEntry?.webappPositionId;
|
|
@@ -137,7 +136,8 @@ export async function recordPositionReviewsTool(args, deps) {
|
|
|
137
136
|
deps.stateStore.recordReview(r.symbol, r.verdict, undefined, r.thesis_status);
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
|
-
|
|
139
|
+
// POSTs above are fire-and-forget by design — durability is the client's
|
|
140
|
+
// in-flight set + drain(), not an await here.
|
|
141
141
|
}
|
|
142
142
|
else {
|
|
143
143
|
logger.info(TAG, `record_position_reviews validated ${reviews.length} reviews; no decisionsClient configured (off-mode).`);
|
package/tools/scan-pairs.js
CHANGED
|
@@ -129,8 +129,20 @@ function learningMatches(learning, setupType, regime) {
|
|
|
129
129
|
}
|
|
130
130
|
export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
131
131
|
const minScore = args.min_score ?? 4;
|
|
132
|
-
//
|
|
133
|
-
|
|
132
|
+
// Facts, strategies, and entry learnings are three independent reads (two
|
|
133
|
+
// intel, one webapp) — start them all now and await in the original order,
|
|
134
|
+
// so error precedence (facts → strategies → learnings-fail-quiet) and every
|
|
135
|
+
// early-return body stay byte-identical while the round-trips overlap.
|
|
136
|
+
const factsPromise = getAllFactsCached(deps);
|
|
137
|
+
const stratPromise = getStrategiesCached(deps);
|
|
138
|
+
const learningsPromise = decisionsDeps?.decisionsClient && decisionsDeps.userId
|
|
139
|
+
? getEntryLearningsCached(decisionsDeps.decisionsClient, decisionsDeps.userId)
|
|
140
|
+
: Promise.resolve([]);
|
|
141
|
+
// An early return below must not leave a floating rejection behind.
|
|
142
|
+
stratPromise.catch(() => { });
|
|
143
|
+
learningsPromise.catch(() => { });
|
|
144
|
+
// 1. All symbol facts (cached, shared with get_setup_detail)
|
|
145
|
+
const factsRes = await factsPromise;
|
|
134
146
|
if ('error' in factsRes)
|
|
135
147
|
return factsRes;
|
|
136
148
|
// Venue scope: only rank symbols this box can actually trade. Without this
|
|
@@ -154,8 +166,8 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
154
166
|
: 'No symbol facts available. Intelligence service may still be computing initial data.',
|
|
155
167
|
};
|
|
156
168
|
}
|
|
157
|
-
// 2.
|
|
158
|
-
const stratRes = await
|
|
169
|
+
// 2. User's active strategies (cached, shared with get_setup_detail)
|
|
170
|
+
const stratRes = await stratPromise;
|
|
159
171
|
if ('error' in stratRes)
|
|
160
172
|
return stratRes;
|
|
161
173
|
const strategies = stratRes;
|
|
@@ -171,13 +183,10 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
171
183
|
}
|
|
172
184
|
// 3. Evaluate all strategies against all facts
|
|
173
185
|
const results = scanAllPairs(strategies, facts, minScore);
|
|
174
|
-
// 4.
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
if (decisionsDeps?.decisionsClient && decisionsDeps.userId) {
|
|
179
|
-
entryLearnings = await getEntryLearningsCached(decisionsDeps.decisionsClient, decisionsDeps.userId);
|
|
180
|
-
}
|
|
186
|
+
// 4. User's confirmed entry-time learnings (optional path — resolves []
|
|
187
|
+
// when the decisions client isn't wired, e.g. in dev or in tests that
|
|
188
|
+
// exercise the pre-learning behaviour).
|
|
189
|
+
const entryLearnings = await learningsPromise;
|
|
181
190
|
const rankings = [];
|
|
182
191
|
const vetoed = [];
|
|
183
192
|
for (const r of results) {
|
package/types.d.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface CcxtPosition {
|
|
|
63
63
|
contractSize: number;
|
|
64
64
|
entryPrice: number;
|
|
65
65
|
markPrice: number;
|
|
66
|
+
/** Paper only: true when NO quote was available for this symbol, so
|
|
67
|
+
* `markPrice` is a fabricated fallback (entryPrice) rather than a real mark.
|
|
68
|
+
* Consumers that make protective decisions MUST NOT read `markPrice` as
|
|
69
|
+
* truth when this is set — a fabricated "flat since entry" mark reads as a
|
|
70
|
+
* healthy position and is how a breached stop stayed invisible for 35h.
|
|
71
|
+
* Undefined on live (real marks come from the exchange). */
|
|
72
|
+
markPriceStale?: boolean;
|
|
66
73
|
notional: number;
|
|
67
74
|
unrealizedPnl: number;
|
|
68
75
|
percentage: number;
|