@reefclaw/openclaw-plugin 0.1.20 → 0.1.22
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 +10 -0
- package/bridge/bridge.js +167 -23
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +11 -0
- package/bridge/provider.d.ts +85 -6
- package/bridge/providers/gateway.d.ts +13 -3
- package/bridge/providers/gateway.js +18 -5
- package/bridge/providers/onboarding-commands.d.ts +38 -7
- package/bridge/providers/onboarding-commands.js +113 -13
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +6 -0
- package/config/plugin-config-io.d.ts +26 -0
- package/config/plugin-config-io.js +47 -0
- package/config/tool-gate.js +4 -0
- package/index.js +79 -12
- package/ingest/readiness-reporter.js +25 -1
- package/openclaw.plugin.json +3 -1
- package/package.json +1 -1
- package/security/sealed-credentials.d.ts +38 -0
- package/security/sealed-credentials.js +180 -0
- package/tools/clear-exchange-credentials.js +16 -3
- package/tools/hl-agent-wallet-status.d.ts +29 -0
- package/tools/hl-agent-wallet-status.js +100 -0
- package/tools/hl-provision-agent-wallet.d.ts +43 -0
- package/tools/hl-provision-agent-wallet.js +173 -0
- package/tools/set-exchange-credentials.d.ts +42 -3
- package/tools/set-exchange-credentials.js +208 -27
- package/tools/set-trading-mode.d.ts +7 -0
- package/tools/set-trading-mode.js +15 -0
- package/tools/test-exchange-credentials.d.ts +30 -3
- package/tools/test-exchange-credentials.js +173 -35
- package/types.d.ts +16 -11
- package/venues/hyperliquid/hl-agent-wallet.d.ts +29 -0
- package/venues/hyperliquid/hl-agent-wallet.js +118 -0
- package/venues/hyperliquid/hl-preflight.d.ts +28 -0
- package/venues/hyperliquid/hl-preflight.js +116 -0
|
@@ -38,11 +38,57 @@ export async function executeSetTradingMode(ctx, mode, acknowledged) {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/** Build the plugin-tool payload for a per-venue credential request. The
|
|
42
|
+
* secret material (Binance secret / HL agent key) passes through verbatim
|
|
43
|
+
* exactly once; the second return value is what must be scrubbed from any
|
|
44
|
+
* error text before it can be logged. */
|
|
45
|
+
function credentialPayload(ctx, req) {
|
|
46
|
+
const confirm = req.confirm_venue_switch === true ? { confirm_venue_switch: true } : {};
|
|
47
|
+
if ('sealed' in req) {
|
|
48
|
+
// End-to-end encrypted envelope — forwarded opaquely; there is no
|
|
49
|
+
// plaintext here to scrub (the empty scrub token never matches anything:
|
|
50
|
+
// executeSet/Test guard on it before building a scrub regex).
|
|
51
|
+
return {
|
|
52
|
+
payload: { sealed: req.sealed, ...confirm, operator_token: ctx.operatorToken },
|
|
53
|
+
scrub: '',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (req.venue === 'hyperliquid') {
|
|
57
|
+
return {
|
|
58
|
+
payload: {
|
|
59
|
+
venue: 'hyperliquid',
|
|
60
|
+
walletAddress: req.walletAddress,
|
|
61
|
+
agentPrivateKey: req.agentPrivateKey,
|
|
62
|
+
testnet: req.testnet,
|
|
63
|
+
...confirm,
|
|
64
|
+
operator_token: ctx.operatorToken,
|
|
65
|
+
},
|
|
66
|
+
scrub: req.agentPrivateKey ?? '',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
payload: {
|
|
71
|
+
venue: 'binance',
|
|
72
|
+
apiKey: req.apiKey,
|
|
73
|
+
secret: req.secret,
|
|
74
|
+
testnet: req.testnet,
|
|
75
|
+
...confirm,
|
|
76
|
+
operator_token: ctx.operatorToken,
|
|
77
|
+
},
|
|
78
|
+
scrub: req.secret ?? '',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function scrubError(reason, scrub) {
|
|
82
|
+
if (!scrub)
|
|
83
|
+
return reason;
|
|
84
|
+
return reason.replace(new RegExp(escapeRegex(scrub), 'g'), '***');
|
|
85
|
+
}
|
|
41
86
|
/** Forward a test_exchange_credentials invocation to the plugin. The plugin
|
|
42
|
-
* does
|
|
43
|
-
*
|
|
44
|
-
* (same approach as the set path). */
|
|
45
|
-
export async function executeTestExchangeCredentials(ctx,
|
|
87
|
+
* does throwaway read-only calls to verify the credentials without
|
|
88
|
+
* persisting them. Error messages are scrubbed of the raw secret / agent key
|
|
89
|
+
* before logging (same approach as the set path). */
|
|
90
|
+
export async function executeTestExchangeCredentials(ctx, req) {
|
|
91
|
+
const testnet = ('testnet' in req ? req.testnet : undefined) ?? false;
|
|
46
92
|
const tool = ctx.toolMap.test_exchange_credentials;
|
|
47
93
|
if (!tool || !ctx.http) {
|
|
48
94
|
return {
|
|
@@ -52,17 +98,18 @@ export async function executeTestExchangeCredentials(ctx, apiKey, secret, testne
|
|
|
52
98
|
canReadBalance: false,
|
|
53
99
|
canReadPositions: false,
|
|
54
100
|
balanceUSDT: null,
|
|
55
|
-
testnet
|
|
101
|
+
testnet,
|
|
56
102
|
errors: ['tool-not-available'],
|
|
57
103
|
};
|
|
58
104
|
}
|
|
105
|
+
const { payload, scrub } = credentialPayload(ctx, req);
|
|
59
106
|
try {
|
|
60
|
-
const result = await ctx.http.invoke(tool,
|
|
107
|
+
const result = await ctx.http.invoke(tool, payload);
|
|
61
108
|
return result.data;
|
|
62
109
|
}
|
|
63
110
|
catch (err) {
|
|
64
111
|
const reason = err instanceof Error ? err.message : String(err);
|
|
65
|
-
const sanitized = reason
|
|
112
|
+
const sanitized = scrubError(reason, scrub);
|
|
66
113
|
logger.error(TAG, `test_exchange_credentials failed: ${sanitized}`);
|
|
67
114
|
return {
|
|
68
115
|
ok: false,
|
|
@@ -71,7 +118,7 @@ export async function executeTestExchangeCredentials(ctx, apiKey, secret, testne
|
|
|
71
118
|
canReadBalance: false,
|
|
72
119
|
canReadPositions: false,
|
|
73
120
|
balanceUSDT: null,
|
|
74
|
-
testnet
|
|
121
|
+
testnet,
|
|
75
122
|
errors: [sanitized],
|
|
76
123
|
};
|
|
77
124
|
}
|
|
@@ -177,11 +224,63 @@ export async function executeSetBracketRequirement(ctx, flag, value) {
|
|
|
177
224
|
};
|
|
178
225
|
}
|
|
179
226
|
}
|
|
227
|
+
/** Forward an hl_provision_agent_wallet invocation to the plugin. No secrets
|
|
228
|
+
* transit this path in EITHER direction — the request carries a public
|
|
229
|
+
* master address; the response carries a public agent address (the private
|
|
230
|
+
* key is minted and stays on the box). */
|
|
231
|
+
export async function executeProvisionHlAgentWallet(ctx, args) {
|
|
232
|
+
const tool = ctx.toolMap.hl_provision_agent_wallet;
|
|
233
|
+
if (!tool || !ctx.http) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
message: 'hl_provision_agent_wallet tool not available on gateway — update the ReefClaw plugin (npx @reefclaw/connect@latest)',
|
|
237
|
+
venue: 'hyperliquid',
|
|
238
|
+
mode: 'PAPER',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const result = await ctx.http.invoke(tool, {
|
|
243
|
+
walletAddress: args.walletAddress,
|
|
244
|
+
testnet: args.testnet,
|
|
245
|
+
regenerate: args.regenerate,
|
|
246
|
+
...(args.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
|
|
247
|
+
operator_token: ctx.operatorToken,
|
|
248
|
+
});
|
|
249
|
+
return result.data;
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
253
|
+
logger.error(TAG, `hl_provision_agent_wallet failed: ${reason}`);
|
|
254
|
+
return { ok: false, message: reason, venue: 'hyperliquid', mode: 'PAPER' };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Forward an hl_agent_wallet_status invocation to the plugin. Read-only. */
|
|
258
|
+
export async function executeHlAgentWalletStatus(ctx) {
|
|
259
|
+
const tool = ctx.toolMap.hl_agent_wallet_status;
|
|
260
|
+
if (!tool || !ctx.http) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
message: 'hl_agent_wallet_status tool not available on gateway — update the ReefClaw plugin (npx @reefclaw/connect@latest)',
|
|
264
|
+
configured: false,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const result = await ctx.http.invoke(tool, {
|
|
269
|
+
operator_token: ctx.operatorToken,
|
|
270
|
+
});
|
|
271
|
+
return result.data;
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
275
|
+
logger.error(TAG, `hl_agent_wallet_status failed: ${reason}`);
|
|
276
|
+
return { ok: false, message: reason, configured: false };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
180
279
|
/** Forward a set_exchange_credentials invocation to the plugin via HTTP.
|
|
181
|
-
* The raw secret is sent once over TLS and never retained here —
|
|
182
|
-
* caching, no log lines with the raw value. Errors are scrubbed of the
|
|
280
|
+
* The raw secret / agent key is sent once over TLS and never retained here —
|
|
281
|
+
* no caching, no log lines with the raw value. Errors are scrubbed of the
|
|
183
282
|
* secret before logging in case it ever appears in an error chain. */
|
|
184
|
-
export async function executeSetExchangeCredentials(ctx,
|
|
283
|
+
export async function executeSetExchangeCredentials(ctx, req) {
|
|
185
284
|
const tool = ctx.toolMap.set_exchange_credentials;
|
|
186
285
|
if (!tool || !ctx.http) {
|
|
187
286
|
return {
|
|
@@ -193,13 +292,14 @@ export async function executeSetExchangeCredentials(ctx, apiKey, secret, testnet
|
|
|
193
292
|
readiness: 'UNKNOWN',
|
|
194
293
|
};
|
|
195
294
|
}
|
|
295
|
+
const { payload, scrub } = credentialPayload(ctx, req);
|
|
196
296
|
try {
|
|
197
|
-
const result = await ctx.http.invoke(tool,
|
|
297
|
+
const result = await ctx.http.invoke(tool, payload);
|
|
198
298
|
return result.data;
|
|
199
299
|
}
|
|
200
300
|
catch (err) {
|
|
201
301
|
const reason = err instanceof Error ? err.message : String(err);
|
|
202
|
-
const sanitized = reason
|
|
302
|
+
const sanitized = scrubError(reason, scrub);
|
|
203
303
|
logger.error(TAG, `set_exchange_credentials failed: ${sanitized}`);
|
|
204
304
|
return {
|
|
205
305
|
ok: false,
|
package/bridge/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { Channel, RequestFrame, ResponseFrame, EventFrame, Frame, Emergency
|
|
|
3
3
|
export { VALID_CHANNELS, VALID_EMERGENCY_ACTIONS } from '@reefclaw/shared';
|
|
4
4
|
type EventFrame = _EventFrame;
|
|
5
5
|
/** Methods the skill accepts from the relay (browser → skill) */
|
|
6
|
-
export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "get_bracket_config", "set_bracket_requirement"];
|
|
6
|
+
export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "get_bracket_config", "set_bracket_requirement"];
|
|
7
7
|
/** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
|
|
8
8
|
* enforces this before dispatching — a session without the scope gets a
|
|
9
9
|
* 403 error. PR2 ships the scope as session-wide (inherited from the Clerk
|
package/bridge/types.js
CHANGED
|
@@ -18,6 +18,9 @@ export const ALLOWED_METHODS = [
|
|
|
18
18
|
'set_exchange_credentials',
|
|
19
19
|
'test_exchange_credentials',
|
|
20
20
|
'clear_exchange_credentials',
|
|
21
|
+
// Hyperliquid guided onboarding (box-generated agent wallet) — operator.write-gated.
|
|
22
|
+
'hl_provision_agent_wallet',
|
|
23
|
+
'hl_agent_wallet_status',
|
|
21
24
|
// Bracket-orders config (Phase 3.5b) — operator.write-gated.
|
|
22
25
|
'get_bracket_config',
|
|
23
26
|
'set_bracket_requirement',
|
|
@@ -33,6 +36,8 @@ export const OPERATOR_WRITE_METHODS = new Set([
|
|
|
33
36
|
'set_exchange_credentials',
|
|
34
37
|
'test_exchange_credentials',
|
|
35
38
|
'clear_exchange_credentials',
|
|
39
|
+
'hl_provision_agent_wallet',
|
|
40
|
+
'hl_agent_wallet_status',
|
|
36
41
|
'get_bracket_config',
|
|
37
42
|
'set_bracket_requirement',
|
|
38
43
|
'emergency.kill',
|
package/ccxt/binance-private.js
CHANGED
|
@@ -73,6 +73,12 @@ export class BinancePrivateApi {
|
|
|
73
73
|
exchange;
|
|
74
74
|
testnet;
|
|
75
75
|
constructor(credentials) {
|
|
76
|
+
// apiKey/secret are optional on ExchangeConfig (an HL block carries a
|
|
77
|
+
// wallet pair instead) — this class is Binance-only and must never be
|
|
78
|
+
// constructed without the HMAC pair (mirrors HyperliquidPrivateApi's guard).
|
|
79
|
+
if (!credentials.apiKey || !credentials.secret) {
|
|
80
|
+
throw new Error('BinancePrivateApi requires exchange.apiKey + exchange.secret');
|
|
81
|
+
}
|
|
76
82
|
this.testnet = credentials.testnet ?? false;
|
|
77
83
|
const BinanceClass = ccxtCjs.binance ?? ccxtCjs.default?.binance;
|
|
78
84
|
if (!BinanceClass) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TradingMode, ExchangeConfig } from '../types.js';
|
|
2
|
+
type VenueId = 'binance' | 'hyperliquid';
|
|
2
3
|
/** The connection the AGENT saves during onboarding lives in OpenClaw's own
|
|
3
4
|
* config (`skills.entries.reefclaw.config` in ~/.openclaw/openclaw.json) —
|
|
4
5
|
* the connector reads it there, and since the chat-install flow never writes
|
|
@@ -231,6 +232,31 @@ export declare function readPluginConfig(path?: string): PluginConfigFile;
|
|
|
231
232
|
* on the next successful write.
|
|
232
233
|
*/
|
|
233
234
|
export declare function updatePluginConfig(patch: Partial<PluginConfigFile>, path?: string): PluginConfigFile;
|
|
235
|
+
/** Which credential fields belong to which venue. They do NOT collide, so one
|
|
236
|
+
* flat `exchange` block can hold both venues at once and `venue` selects the
|
|
237
|
+
* active one (every reader — boot, set_trading_mode, the adapters — already
|
|
238
|
+
* reads only the active venue's fields). */
|
|
239
|
+
export declare const VENUE_CREDENTIAL_FIELDS: Readonly<Record<VenueId, readonly (keyof ExchangeConfig)[]>>;
|
|
240
|
+
/** True when `exchange` holds a usable credential set for `venue`. */
|
|
241
|
+
export declare function hasCredentialsForVenue(exchange: ExchangeConfig | undefined, venue: VenueId): boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Build the `exchange` block for activating `venue`, PRESERVING every setting
|
|
244
|
+
* already configured for the other venue.
|
|
245
|
+
*
|
|
246
|
+
* ★ Why this exists: `updatePluginConfig` replaces `exchange` wholesale (its
|
|
247
|
+
* documented contract — `exchange: null` is how credentials are cleared), so
|
|
248
|
+
* storing Hyperliquid credentials used to DELETE the Binance key/secret and
|
|
249
|
+
* vice versa. A trader who runs both venues then had to re-enter keys on every
|
|
250
|
+
* switch, and a live Binance box could silently lose its credentials while
|
|
251
|
+
* setting up Hyperliquid. Operator decision (2026-08-02): switching venues
|
|
252
|
+
* must be a warn-and-confirm, never a data-losing action.
|
|
253
|
+
*
|
|
254
|
+
* `credentials` carries only the incoming venue's fields; omit them entirely to
|
|
255
|
+
* ACTIVATE a venue whose credentials are already stored (the one-click
|
|
256
|
+
* switch-back). `testnet` falls back to that venue's remembered flag.
|
|
257
|
+
*/
|
|
258
|
+
export declare function buildVenueExchangeConfig(existing: ExchangeConfig | undefined, venue: VenueId, credentials: Partial<ExchangeConfig>, testnet?: boolean): ExchangeConfig;
|
|
234
259
|
/** Redact a credential for logging — keeps first 4 and last 2 chars.
|
|
235
260
|
* Never log the full key/secret. */
|
|
236
261
|
export declare function redactCredential(value: string | undefined | null): string;
|
|
262
|
+
export {};
|
|
@@ -144,6 +144,53 @@ export function updatePluginConfig(patch, path = defaultConfigPath()) {
|
|
|
144
144
|
renameSync(tmpPath, path);
|
|
145
145
|
return merged;
|
|
146
146
|
}
|
|
147
|
+
/** Which credential fields belong to which venue. They do NOT collide, so one
|
|
148
|
+
* flat `exchange` block can hold both venues at once and `venue` selects the
|
|
149
|
+
* active one (every reader — boot, set_trading_mode, the adapters — already
|
|
150
|
+
* reads only the active venue's fields). */
|
|
151
|
+
export const VENUE_CREDENTIAL_FIELDS = {
|
|
152
|
+
binance: ['apiKey', 'secret'],
|
|
153
|
+
hyperliquid: ['walletAddress', 'agentPrivateKey'],
|
|
154
|
+
};
|
|
155
|
+
/** True when `exchange` holds a usable credential set for `venue`. */
|
|
156
|
+
export function hasCredentialsForVenue(exchange, venue) {
|
|
157
|
+
if (!exchange)
|
|
158
|
+
return false;
|
|
159
|
+
return VENUE_CREDENTIAL_FIELDS[venue].every((f) => {
|
|
160
|
+
const v = exchange[f];
|
|
161
|
+
return typeof v === 'string' && v.length > 0;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Build the `exchange` block for activating `venue`, PRESERVING every setting
|
|
166
|
+
* already configured for the other venue.
|
|
167
|
+
*
|
|
168
|
+
* ★ Why this exists: `updatePluginConfig` replaces `exchange` wholesale (its
|
|
169
|
+
* documented contract — `exchange: null` is how credentials are cleared), so
|
|
170
|
+
* storing Hyperliquid credentials used to DELETE the Binance key/secret and
|
|
171
|
+
* vice versa. A trader who runs both venues then had to re-enter keys on every
|
|
172
|
+
* switch, and a live Binance box could silently lose its credentials while
|
|
173
|
+
* setting up Hyperliquid. Operator decision (2026-08-02): switching venues
|
|
174
|
+
* must be a warn-and-confirm, never a data-losing action.
|
|
175
|
+
*
|
|
176
|
+
* `credentials` carries only the incoming venue's fields; omit them entirely to
|
|
177
|
+
* ACTIVATE a venue whose credentials are already stored (the one-click
|
|
178
|
+
* switch-back). `testnet` falls back to that venue's remembered flag.
|
|
179
|
+
*/
|
|
180
|
+
export function buildVenueExchangeConfig(existing, venue, credentials, testnet) {
|
|
181
|
+
const next = { ...(existing ?? {}), venue };
|
|
182
|
+
for (const field of VENUE_CREDENTIAL_FIELDS[venue]) {
|
|
183
|
+
const incoming = credentials[field];
|
|
184
|
+
if (typeof incoming === 'string' && incoming.length > 0) {
|
|
185
|
+
// Assigning through the union needs a cast; every field is string-typed.
|
|
186
|
+
next[field] = incoming;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const resolvedTestnet = testnet ?? existing?.testnetByVenue?.[venue] ?? (existing?.venue === venue ? existing?.testnet : undefined) ?? false;
|
|
190
|
+
next.testnet = resolvedTestnet;
|
|
191
|
+
next.testnetByVenue = { ...(existing?.testnetByVenue ?? {}), [venue]: resolvedTestnet };
|
|
192
|
+
return next;
|
|
193
|
+
}
|
|
147
194
|
/** Redact a credential for logging — keeps first 4 and last 2 chars.
|
|
148
195
|
* Never log the full key/secret. */
|
|
149
196
|
export function redactCredential(value) {
|
package/config/tool-gate.js
CHANGED
|
@@ -31,6 +31,10 @@ export const UNGOVERNABLE_TOOLS = new Set([
|
|
|
31
31
|
'set_trading_mode',
|
|
32
32
|
'get_bracket_config',
|
|
33
33
|
'set_bracket_requirement',
|
|
34
|
+
// Hyperliquid guided onboarding (box-generated agent wallet) — same
|
|
35
|
+
// operator-only channel; disabling them would strand the HL setup flow.
|
|
36
|
+
'hl_provision_agent_wallet',
|
|
37
|
+
'hl_agent_wallet_status',
|
|
34
38
|
]);
|
|
35
39
|
/** RC_TOOL_GATE: 'off' = kill-switch (gate never blocks), 'shadow' = log
|
|
36
40
|
* would-be blocks but execute anyway, anything else/default = 'enforce'.
|
package/index.js
CHANGED
|
@@ -66,6 +66,9 @@ import { getBracketConfigTool } from './tools/get-bracket-config.js';
|
|
|
66
66
|
import { setBracketRequirementTool } from './tools/set-bracket-requirement.js';
|
|
67
67
|
import { testExchangeCredentialsTool } from './tools/test-exchange-credentials.js';
|
|
68
68
|
import { clearExchangeCredentialsTool } from './tools/clear-exchange-credentials.js';
|
|
69
|
+
import { hlProvisionAgentWalletTool } from './tools/hl-provision-agent-wallet.js';
|
|
70
|
+
import { hlAgentWalletStatusTool } from './tools/hl-agent-wallet-status.js';
|
|
71
|
+
import { ensureCredentialTransportKey } from './security/sealed-credentials.js';
|
|
69
72
|
// Tool implementations
|
|
70
73
|
import { fetchTickerTool } from './tools/fetch-ticker.js';
|
|
71
74
|
import { fetchOhlcvTool } from './tools/fetch-ohlcv.js';
|
|
@@ -798,11 +801,15 @@ const TOOL_PARAMS = {
|
|
|
798
801
|
type: 'object',
|
|
799
802
|
properties: {
|
|
800
803
|
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
+
venue: { type: 'string', enum: ['binance', 'hyperliquid'], description: "Trading venue the credentials are for (default 'binance')" },
|
|
805
|
+
apiKey: { type: 'string', description: 'Binance API key (venue=binance)' },
|
|
806
|
+
secret: { type: 'string', description: 'Binance API secret (venue=binance)' },
|
|
807
|
+
walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… (venue=hyperliquid; never a private key)' },
|
|
808
|
+
agentPrivateKey: { type: 'string', description: 'Hyperliquid AGENT (API) wallet private key — signs only, cannot withdraw (venue=hyperliquid)' },
|
|
809
|
+
testnet: { type: 'boolean', description: 'Use the venue testnet (default: false)' },
|
|
810
|
+
confirm_venue_switch: { type: 'boolean', description: 'Acknowledge switching this agent to another venue while it is configured live on the current one. Credentials for both venues are kept either way.' },
|
|
811
|
+
sealed: { type: 'object', description: 'End-to-end encrypted credential envelope from the dashboard (replaces the plaintext fields; only this box can open it)' },
|
|
804
812
|
},
|
|
805
|
-
required: ['apiKey', 'secret'],
|
|
806
813
|
},
|
|
807
814
|
set_trading_mode: {
|
|
808
815
|
type: 'object',
|
|
@@ -817,11 +824,31 @@ const TOOL_PARAMS = {
|
|
|
817
824
|
type: 'object',
|
|
818
825
|
properties: {
|
|
819
826
|
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
827
|
+
venue: { type: 'string', enum: ['binance', 'hyperliquid'], description: "Trading venue to verify against (default 'binance')" },
|
|
828
|
+
apiKey: { type: 'string', description: 'Binance API key to verify (venue=binance; not persisted)' },
|
|
829
|
+
secret: { type: 'string', description: 'Binance API secret to verify (venue=binance; not persisted)' },
|
|
830
|
+
walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… to verify (venue=hyperliquid; not persisted)' },
|
|
831
|
+
agentPrivateKey: { type: 'string', description: 'Hyperliquid AGENT wallet private key to verify (venue=hyperliquid; not persisted)' },
|
|
832
|
+
testnet: { type: 'boolean', description: 'Test against the venue testnet' },
|
|
833
|
+
sealed: { type: 'object', description: 'End-to-end encrypted credential envelope from the dashboard (replaces the plaintext fields; only this box can open it)' },
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
hl_provision_agent_wallet: {
|
|
837
|
+
type: 'object',
|
|
838
|
+
properties: {
|
|
839
|
+
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
840
|
+
walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… the agent wallet will trade for (public; never a private key)' },
|
|
841
|
+
testnet: { type: 'boolean', description: 'Provision for the Hyperliquid testnet (default: false)' },
|
|
842
|
+
regenerate: { type: 'boolean', description: 'Replace an existing agent wallet with a fresh key (PAPER mode only; the old approval is orphaned)' },
|
|
843
|
+
confirm_venue_switch: { type: 'boolean', description: 'Acknowledge switching this agent to Hyperliquid while it is configured live on another venue. Credentials for both venues are kept either way.' },
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
hl_agent_wallet_status: {
|
|
847
|
+
type: 'object',
|
|
848
|
+
properties: {
|
|
849
|
+
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
850
|
+
probe: { type: 'boolean', description: 'Tool-discovery probe — returns immediately without config or network access' },
|
|
823
851
|
},
|
|
824
|
-
required: ['apiKey', 'secret'],
|
|
825
852
|
},
|
|
826
853
|
clear_exchange_credentials: {
|
|
827
854
|
type: 'object',
|
|
@@ -2639,13 +2666,13 @@ const paperTradingPlugin = {
|
|
|
2639
2666
|
{
|
|
2640
2667
|
name: 'set_exchange_credentials',
|
|
2641
2668
|
label: 'Set Exchange Credentials',
|
|
2642
|
-
description: 'Operator-only. Store
|
|
2669
|
+
description: 'Operator-only. Store exchange credentials in ~/.reefclaw/plugin-config.json — Binance API key + secret, or Hyperliquid master address + agent wallet key (venue-selected). Same-venue live modes reconnect the adapter; a venue CHANGE is stored but requires an agent restart. Refused without dashboard operator provenance.',
|
|
2643
2670
|
parameters: TOOL_PARAMS.set_exchange_credentials,
|
|
2644
2671
|
execute: async (_id, params) => {
|
|
2645
2672
|
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2646
2673
|
if (!prov.ok)
|
|
2647
2674
|
return jsonResult({ error: prov.error });
|
|
2648
|
-
return jsonResult(await setExchangeCredentialsTool(params, { runtime, adapterDeps }));
|
|
2675
|
+
return jsonResult(await setExchangeCredentialsTool(params, { runtime, adapterDeps, bootVenue: venue }));
|
|
2649
2676
|
},
|
|
2650
2677
|
},
|
|
2651
2678
|
{
|
|
@@ -2657,13 +2684,13 @@ const paperTradingPlugin = {
|
|
|
2657
2684
|
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2658
2685
|
if (!prov.ok)
|
|
2659
2686
|
return jsonResult({ error: prov.error });
|
|
2660
|
-
return jsonResult(await setTradingModeTool(params, { runtime, adapterDeps }));
|
|
2687
|
+
return jsonResult(await setTradingModeTool(params, { runtime, adapterDeps, bootVenue: venue }));
|
|
2661
2688
|
},
|
|
2662
2689
|
},
|
|
2663
2690
|
{
|
|
2664
2691
|
name: 'test_exchange_credentials',
|
|
2665
2692
|
label: 'Test Exchange Credentials',
|
|
2666
|
-
description: 'Operator-only. Verify
|
|
2693
|
+
description: 'Operator-only. Verify candidate exchange credentials with read-only calls, per venue (Binance: fetchBalance permissions; Hyperliquid: master account state + agent-approval check), without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials. Refused without dashboard operator provenance.',
|
|
2667
2694
|
parameters: TOOL_PARAMS.test_exchange_credentials,
|
|
2668
2695
|
// Read-only, but provenance-gated anyway: it makes a live authenticated
|
|
2669
2696
|
// exchange call with caller-supplied keys, which would otherwise hand a
|
|
@@ -2687,6 +2714,35 @@ const paperTradingPlugin = {
|
|
|
2687
2714
|
return jsonResult(await clearExchangeCredentialsTool(params, { runtime, adapterDeps }));
|
|
2688
2715
|
},
|
|
2689
2716
|
},
|
|
2717
|
+
{
|
|
2718
|
+
name: 'hl_provision_agent_wallet',
|
|
2719
|
+
label: 'Provision Hyperliquid Agent Wallet',
|
|
2720
|
+
description: 'Operator-only. Generate a Hyperliquid agent-wallet keypair ON THIS MACHINE (the private key never leaves it, nobody ever sees it) and return only the public address for the operator to approve on Hyperliquid. Idempotent — an existing wallet is returned, not replaced, unless {regenerate: true} (PAPER mode only). Refused without dashboard operator provenance.',
|
|
2721
|
+
parameters: TOOL_PARAMS.hl_provision_agent_wallet,
|
|
2722
|
+
execute: async (_id, params) => {
|
|
2723
|
+
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2724
|
+
if (!prov.ok)
|
|
2725
|
+
return jsonResult({ error: prov.error });
|
|
2726
|
+
return jsonResult(await hlProvisionAgentWalletTool(params, { runtime, bootVenue: venue }));
|
|
2727
|
+
},
|
|
2728
|
+
},
|
|
2729
|
+
{
|
|
2730
|
+
name: 'hl_agent_wallet_status',
|
|
2731
|
+
label: 'Hyperliquid Agent Wallet Status',
|
|
2732
|
+
description: 'Operator-only. Report the provisioned Hyperliquid agent wallet (public address, approval status via extraAgents, expiry, master balance). The dashboard polls this after the operator signs the approval. Read-only, keyless exchange queries. Refused without dashboard operator provenance.',
|
|
2733
|
+
parameters: TOOL_PARAMS.hl_agent_wallet_status,
|
|
2734
|
+
execute: async (_id, params) => {
|
|
2735
|
+
// The probe path must answer BEFORE the provenance gate — tool
|
|
2736
|
+
// discovery has no operator token, and a provenance refusal is an
|
|
2737
|
+
// envelope-level error the prober can't tell apart from execution.
|
|
2738
|
+
if (params?.probe === true)
|
|
2739
|
+
return jsonResult(await hlAgentWalletStatusTool(params, {}));
|
|
2740
|
+
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2741
|
+
if (!prov.ok)
|
|
2742
|
+
return jsonResult({ error: prov.error });
|
|
2743
|
+
return jsonResult(await hlAgentWalletStatusTool(params, { bootVenue: venue }));
|
|
2744
|
+
},
|
|
2745
|
+
},
|
|
2690
2746
|
{
|
|
2691
2747
|
name: 'get_bracket_config',
|
|
2692
2748
|
label: 'Get Bracket Config',
|
|
@@ -2778,6 +2834,17 @@ const paperTradingPlugin = {
|
|
|
2778
2834
|
// instead of a false green. Probes ONLY the configured venue — on a
|
|
2779
2835
|
// Binance-451 host trading Hyperliquid a Binance probe would be a
|
|
2780
2836
|
// permanent false alarm. Advisory + fire-and-forget; no token → no-op.
|
|
2837
|
+
// Sealed credential transport (security/sealed-credentials.ts): make sure
|
|
2838
|
+
// this box's static keypair exists BEFORE the first readiness report goes
|
|
2839
|
+
// out — the report carries the PUBLIC half so the dashboard can encrypt
|
|
2840
|
+
// credential payloads that only this box can open. Non-fatal: without a
|
|
2841
|
+
// key the dashboard falls back to the legacy plaintext-over-TLS path.
|
|
2842
|
+
try {
|
|
2843
|
+
ensureCredentialTransportKey();
|
|
2844
|
+
}
|
|
2845
|
+
catch (err) {
|
|
2846
|
+
logger.warn(TAG, `credential transport key setup failed (plaintext fallback stays available): ${formatError(err)}`);
|
|
2847
|
+
}
|
|
2781
2848
|
// Runs here once (guarded by the pluginInitialised early-return → once per
|
|
2782
2849
|
// process) + on an unref'd interval inside the reporter.
|
|
2783
2850
|
startReadinessReporter({
|
|
@@ -214,12 +214,36 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
214
214
|
checks.push(makeReadinessCheck(reachId, 'unknown', { checkedAt: now }));
|
|
215
215
|
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
216
216
|
}
|
|
217
|
+
// Credential transport PUBLIC key (security/sealed-credentials.ts): riding
|
|
218
|
+
// the readiness report is what lets the dashboard encrypt credential
|
|
219
|
+
// payloads that only this box can open. Publishing the public half is the
|
|
220
|
+
// key's entire purpose — this is NOT an egress of secret material. Absent
|
|
221
|
+
// key (setup failed) → field omitted → dashboard falls back to plaintext.
|
|
222
|
+
let credentialPublicKey;
|
|
223
|
+
let credentialKeyId;
|
|
224
|
+
try {
|
|
225
|
+
const { getCredentialTransportPublicKey } = await import('../security/sealed-credentials.js');
|
|
226
|
+
const info = getCredentialTransportPublicKey();
|
|
227
|
+
if (info) {
|
|
228
|
+
credentialPublicKey = info.publicKeyBase64;
|
|
229
|
+
credentialKeyId = info.keyId;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Best-effort — readiness must never fail on the transport key.
|
|
234
|
+
}
|
|
217
235
|
return {
|
|
218
236
|
schemaVersion: 1,
|
|
219
237
|
generatedAt: now,
|
|
220
238
|
overall: deriveOverallReadiness(checks),
|
|
221
239
|
checks,
|
|
222
|
-
agent: {
|
|
240
|
+
agent: {
|
|
241
|
+
pluginVersion: PLUGIN_VERSION,
|
|
242
|
+
toolCount: opts.toolCount,
|
|
243
|
+
venue: opts.venue,
|
|
244
|
+
...(credentialPublicKey ? { credentialPublicKey } : {}),
|
|
245
|
+
...(credentialKeyId ? { credentialKeyId } : {}),
|
|
246
|
+
},
|
|
223
247
|
};
|
|
224
248
|
}
|
|
225
249
|
async function postReadiness(apiBaseUrl, token, report, fetchImpl, timeoutMs) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.22",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|
|
@@ -70,6 +70,8 @@
|
|
|
70
70
|
"set_trading_mode",
|
|
71
71
|
"test_exchange_credentials",
|
|
72
72
|
"clear_exchange_credentials",
|
|
73
|
+
"hl_provision_agent_wallet",
|
|
74
|
+
"hl_agent_wallet_status",
|
|
73
75
|
"get_bracket_config",
|
|
74
76
|
"set_bracket_requirement"
|
|
75
77
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode \u00e2\u20ac\u201d it cannot trade real funds until you supply exchange credentials and walk the PAPER\u00e2\u2020\u2019MICRO_LIVE\u00e2\u2020\u2019LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw \u00e2\u20ac\u201d a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare class SealedEnvelopeError extends Error {
|
|
2
|
+
/** 'key_mismatch' → the browser sealed to a stale/foreign key (refresh the
|
|
3
|
+
* dashboard so it refetches this box's current key); 'malformed' → not a
|
|
4
|
+
* v1 envelope; 'decrypt_failed' → tampered or corrupted in transit. */
|
|
5
|
+
readonly reason: 'key_mismatch' | 'malformed' | 'decrypt_failed';
|
|
6
|
+
constructor(message: string,
|
|
7
|
+
/** 'key_mismatch' → the browser sealed to a stale/foreign key (refresh the
|
|
8
|
+
* dashboard so it refetches this box's current key); 'malformed' → not a
|
|
9
|
+
* v1 envelope; 'decrypt_failed' → tampered or corrupted in transit. */
|
|
10
|
+
reason: 'key_mismatch' | 'malformed' | 'decrypt_failed');
|
|
11
|
+
}
|
|
12
|
+
export interface SealedEnvelope {
|
|
13
|
+
v: 1;
|
|
14
|
+
/** First 16 hex chars of SHA-256(recipient public key, 65-byte uncompressed). */
|
|
15
|
+
kid: string;
|
|
16
|
+
/** Sender's ephemeral public key — base64, 65-byte uncompressed point. */
|
|
17
|
+
epk: string;
|
|
18
|
+
/** base64, 12 bytes. */
|
|
19
|
+
iv: string;
|
|
20
|
+
/** base64, ciphertext with the 16-byte GCM tag appended. */
|
|
21
|
+
ct: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function computeKeyId(publicKeyRaw: Buffer): string;
|
|
24
|
+
export interface TransportKeyInfo {
|
|
25
|
+
publicKeyBase64: string;
|
|
26
|
+
keyId: string;
|
|
27
|
+
}
|
|
28
|
+
/** Ensure the box's static transport keypair exists (generate + persist 0600 on
|
|
29
|
+
* first run) and return the PUBLIC half for the readiness report. Never
|
|
30
|
+
* returns or logs the private half. */
|
|
31
|
+
export declare function ensureCredentialTransportKey(dir?: string): TransportKeyInfo;
|
|
32
|
+
/** The PUBLIC half for the readiness report, or null when no key exists yet
|
|
33
|
+
* (never generates — boot calls ensureCredentialTransportKey first). */
|
|
34
|
+
export declare function getCredentialTransportPublicKey(dir?: string): TransportKeyInfo | null;
|
|
35
|
+
/** Open a sealed credential envelope with this box's private transport key.
|
|
36
|
+
* Returns the decrypted JSON payload (plain object). Throws
|
|
37
|
+
* SealedEnvelopeError with a caller-renderable reason on any failure. */
|
|
38
|
+
export declare function unsealCredentials(envelope: unknown, dir?: string): Record<string, unknown>;
|