@reefclaw/openclaw-plugin 0.1.19 → 0.1.21

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 (38) hide show
  1. package/bridge/bridge.d.ts +10 -0
  2. package/bridge/bridge.js +142 -22
  3. package/bridge/gateway/gateway-ws-client.d.ts +7 -1
  4. package/bridge/gateway/gateway-ws-client.js +31 -8
  5. package/bridge/gateway/tool-discovery.d.ts +1 -1
  6. package/bridge/gateway/tool-discovery.js +11 -0
  7. package/bridge/provider.d.ts +73 -6
  8. package/bridge/providers/gateway.d.ts +12 -3
  9. package/bridge/providers/gateway.js +18 -5
  10. package/bridge/providers/onboarding-commands.d.ts +33 -7
  11. package/bridge/providers/onboarding-commands.js +109 -13
  12. package/bridge/types.d.ts +1 -1
  13. package/bridge/types.js +5 -0
  14. package/ccxt/binance-private.js +6 -0
  15. package/config/tool-gate.js +4 -0
  16. package/index.js +77 -12
  17. package/ingest/position-auto-capture.js +19 -0
  18. package/ingest/readiness-reporter.js +25 -1
  19. package/openclaw.plugin.json +3 -1
  20. package/package.json +38 -38
  21. package/security/sealed-credentials.d.ts +38 -0
  22. package/security/sealed-credentials.js +180 -0
  23. package/tools/clear-exchange-credentials.js +16 -3
  24. package/tools/hl-agent-wallet-status.d.ts +29 -0
  25. package/tools/hl-agent-wallet-status.js +100 -0
  26. package/tools/hl-provision-agent-wallet.d.ts +35 -0
  27. package/tools/hl-provision-agent-wallet.js +144 -0
  28. package/tools/set-exchange-credentials.d.ts +32 -3
  29. package/tools/set-exchange-credentials.js +144 -32
  30. package/tools/set-trading-mode.d.ts +7 -0
  31. package/tools/set-trading-mode.js +15 -0
  32. package/tools/test-exchange-credentials.d.ts +30 -3
  33. package/tools/test-exchange-credentials.js +173 -35
  34. package/types.d.ts +10 -11
  35. package/venues/hyperliquid/hl-agent-wallet.d.ts +29 -0
  36. package/venues/hyperliquid/hl-agent-wallet.js +118 -0
  37. package/venues/hyperliquid/hl-preflight.d.ts +28 -0
  38. package/venues/hyperliquid/hl-preflight.js +116 -0
@@ -1,19 +1,66 @@
1
1
  // set_exchange_credentials — operator-only plugin tool.
2
2
  //
3
- // Writes Binance API key + secret to ~/.reefclaw/plugin-config.json and
4
- // triggers an adapter reconnect if the current mode is live. The agent
5
- // should NOT call this tool; it's registered for the skill's operator RPC
6
- // path only. PR2 will enforce that via a skill-side scope gate (operator.write).
3
+ // Writes exchange credentials to ~/.reefclaw/plugin-config.json — per VENUE:
4
+ // binance (default when `venue` is absent every legacy caller byte-identical):
5
+ // apiKey + secret (HMAC pair).
6
+ // hyperliquid: walletAddress (MASTER, 0x… queries/funds; NEVER its private
7
+ // key) + agentPrivateKey (an approved agent/API wallet key — signs only).
8
+ //
9
+ // The `exchange` block is replaced WHOLESALE (the documented contract in
10
+ // plugin-config-io.ts) — storing credentials for one venue deliberately removes
11
+ // the other venue's keys from disk, and `venue` is always carried through so
12
+ // the historical footgun (a Binance-shaped write silently reverting an HL box
13
+ // to the binance default) is impossible from the venue-tabbed dashboard.
14
+ //
15
+ // Venue CHANGE is restart-required: the paper market feed, symbols, quote
16
+ // currency, and readiness prober are venue-wired at register() (plan §5.2).
17
+ // When the requested venue differs from the venue this process booted with, the
18
+ // credentials are written but NO reconnect happens — the result carries
19
+ // `restartRequired: true` and the dashboard tells the operator to restart the
20
+ // agent (`/restart` in chat). Same-venue behavior is unchanged: PAPER stores
21
+ // without activating; live modes hot-reconnect the adapter.
7
22
  //
8
23
  // Security:
9
- // - The secret is NEVER logged. Only a redacted fingerprint (first 4 + last 2
10
- // chars of the apiKey) goes to logs / audit.
11
- // - If the current mode is PAPER, credentials are written but NOT activated —
12
- // the operator must explicitly call set_trading_mode to move up the ladder.
24
+ // - Secrets (Binance secret / HL agent key) are NEVER logged. Only a redacted
25
+ // fingerprint (Binance apiKey prefix, or the PUBLIC master address for HL)
26
+ // goes to logs / audit.
27
+ // - The HL arm derives the address the pasted agent key controls and REFUSES
28
+ // to store it when it equals the master walletAddress — that means the user
29
+ // pasted their MASTER private key, the one mistake that must never reach
30
+ // disk. Fail-open when derivation infra is unavailable (never block on it).
13
31
  import { updatePluginConfig, redactCredential } from '../config/plugin-config-io.js';
32
+ import { deriveAddressFromPrivateKey, isHexAddress, isHexPrivateKey, normalizeHexPrivateKey, sameAddress, } from '../venues/hyperliquid/hl-agent-wallet.js';
33
+ import { parseVenue } from '../venues/registry.js';
34
+ import { unsealCredentials, SealedEnvelopeError } from '../security/sealed-credentials.js';
14
35
  import { logger } from '../logger.js';
15
36
  const TAG = 'set-exchange-credentials';
16
- function validate(args) {
37
+ /** Open a sealed envelope into plain args, or return a renderable error. */
38
+ export function resolveSealedArgs(args, configDir) {
39
+ if (args?.sealed == null)
40
+ return { args, sealed: false };
41
+ try {
42
+ const plain = unsealCredentials(args.sealed, configDir);
43
+ return { args: plain, sealed: true };
44
+ }
45
+ catch (err) {
46
+ const message = err instanceof SealedEnvelopeError
47
+ ? `Encrypted credentials could not be opened: ${err.message}`
48
+ : 'Encrypted credentials could not be opened — try again';
49
+ return { error: message };
50
+ }
51
+ }
52
+ function fail(message, fingerprint, deps, venue) {
53
+ return {
54
+ ok: false,
55
+ message,
56
+ fingerprint,
57
+ reconnected: false,
58
+ mode: deps.runtime.mode,
59
+ readiness: deps.runtime.adapter.readiness,
60
+ ...(venue ? { venue } : {}),
61
+ };
62
+ }
63
+ function validateBinance(args) {
17
64
  if (typeof args.apiKey !== 'string' || args.apiKey.trim().length < 8) {
18
65
  return 'apiKey must be a string of at least 8 characters';
19
66
  }
@@ -25,56 +72,121 @@ function validate(args) {
25
72
  }
26
73
  return null;
27
74
  }
28
- export async function setExchangeCredentialsTool(args, deps) {
29
- const validationError = validate(args);
75
+ function validateHyperliquid(args) {
76
+ if (!isHexAddress(args.walletAddress)) {
77
+ return 'walletAddress must be your MASTER account address: 0x followed by 40 hex characters';
78
+ }
79
+ if (!isHexPrivateKey(args.agentPrivateKey)) {
80
+ return 'agentPrivateKey must be an agent (API) wallet private key: 64 hex characters (0x prefix optional)';
81
+ }
82
+ if (args.testnet !== undefined && typeof args.testnet !== 'boolean') {
83
+ return 'testnet must be a boolean when provided';
84
+ }
85
+ return null;
86
+ }
87
+ export async function setExchangeCredentialsTool(rawArgs, deps) {
88
+ const resolved = resolveSealedArgs(rawArgs, deps.transportKeyDir);
89
+ if ('error' in resolved) {
90
+ return fail(resolved.error, '(sealed)', deps);
91
+ }
92
+ const args = resolved.args;
93
+ const venue = parseVenue(args.venue).venue;
94
+ const bootVenue = deps.bootVenue ?? 'binance';
95
+ const validationError = venue === 'hyperliquid' ? validateHyperliquid(args) : validateBinance(args);
30
96
  if (validationError) {
31
- return {
32
- ok: false,
33
- message: validationError,
34
- fingerprint: '(not-set)',
35
- reconnected: false,
36
- mode: deps.runtime.mode,
37
- readiness: deps.runtime.adapter.readiness,
97
+ return fail(validationError, '(not-set)', deps, venue);
98
+ }
99
+ let exchange;
100
+ let fingerprint;
101
+ if (venue === 'hyperliquid') {
102
+ const walletAddress = args.walletAddress.trim();
103
+ const agentPrivateKey = normalizeHexPrivateKey(args.agentPrivateKey);
104
+ // The one unrecoverable mistake: the pasted "agent key" controls the MASTER
105
+ // address ⇒ the user pasted their master wallet's private key. Refuse to
106
+ // persist it. Derivation-unavailable fails OPEN (store proceeds).
107
+ const derived = await deriveAddressFromPrivateKey(agentPrivateKey);
108
+ if (derived.ok && sameAddress(derived.address, walletAddress)) {
109
+ return fail('REFUSED: that private key controls the master wallet address you entered — you pasted your ' +
110
+ 'MASTER wallet key, which can withdraw funds. Never share it. In the Hyperliquid app open ' +
111
+ 'More → API, generate an agent wallet, approve it, and paste the AGENT key instead.', redactCredential(walletAddress), deps, venue);
112
+ }
113
+ if (!derived.ok && derived.reason === 'invalid_key') {
114
+ return fail('agentPrivateKey is not a valid secp256k1 private key — re-copy it from the Hyperliquid API page', redactCredential(walletAddress), deps, venue);
115
+ }
116
+ exchange = {
117
+ venue: 'hyperliquid',
118
+ walletAddress,
119
+ agentPrivateKey,
120
+ testnet: args.testnet ?? false,
38
121
  };
122
+ // The master ADDRESS is public (it identifies the account on-chain) — safe
123
+ // as a fingerprint; the agent key never appears anywhere.
124
+ fingerprint = redactCredential(walletAddress);
125
+ }
126
+ else {
127
+ exchange = {
128
+ // Legacy callers omit `venue`; keep their on-disk shape byte-identical.
129
+ ...(args.venue !== undefined ? { venue: 'binance' } : {}),
130
+ apiKey: args.apiKey.trim(),
131
+ secret: args.secret.trim(),
132
+ testnet: args.testnet ?? false,
133
+ };
134
+ fingerprint = redactCredential(exchange.apiKey);
39
135
  }
40
- const exchange = {
41
- apiKey: args.apiKey.trim(),
42
- secret: args.secret.trim(),
43
- testnet: args.testnet ?? false,
44
- };
45
- const fingerprint = redactCredential(exchange.apiKey);
46
136
  try {
47
137
  updatePluginConfig({ exchange }, deps.configPath);
48
138
  }
49
139
  catch (err) {
50
140
  const msg = err instanceof Error ? err.message : String(err);
51
141
  logger.error(TAG, `Failed to write credentials: ${msg}`);
142
+ return fail(`Failed to persist credentials: ${msg}`, fingerprint, deps, venue);
143
+ }
144
+ logger.info(TAG, `Stored ${venue} credentials (fingerprint: ${fingerprint}, testnet: ${exchange.testnet === true})`);
145
+ // Venue change: the paper feed / symbols / quote currency / readiness prober
146
+ // are wired to the BOOT venue — hot-reconnecting a live adapter into a
147
+ // different venue would run it on top of the old venue's paper stack. Write,
148
+ // don't activate; the restart applies everything coherently.
149
+ if (venue !== bootVenue) {
52
150
  return {
53
- ok: false,
54
- message: `Failed to persist credentials: ${msg}`,
151
+ ok: true,
152
+ message: `Credentials stored and venue set to ${venue} — restart your agent to apply ` +
153
+ `(send /restart to your agent in chat, or restart the OpenClaw service)`,
55
154
  fingerprint,
56
155
  reconnected: false,
57
156
  mode: deps.runtime.mode,
58
157
  readiness: deps.runtime.adapter.readiness,
158
+ venue,
159
+ restartRequired: true,
59
160
  };
60
161
  }
61
- logger.info(TAG, `Stored credentials (fingerprint: ${fingerprint}, testnet: ${exchange.testnet})`);
62
- // Only reconnect if we're already in a live mode if the operator is still
63
- // in PAPER, activating the keys is a separate deliberate step via
64
- // set_trading_mode. This prevents accidentally going live by saving keys.
162
+ // Same venue: only reconnect if we're already in a live mode — if the
163
+ // operator is still in PAPER, activating the keys is a separate deliberate
164
+ // step via set_trading_mode. This prevents accidentally going live by saving
165
+ // keys.
65
166
  let reconnected = false;
66
167
  if (deps.runtime.mode !== 'PAPER') {
67
- await deps.runtime.reconnect({ mode: deps.runtime.mode, exchange }, { adapterDeps: deps.adapterDeps });
168
+ const hlCredentials = venue === 'hyperliquid'
169
+ ? {
170
+ walletAddress: exchange.walletAddress,
171
+ agentPrivateKey: exchange.agentPrivateKey,
172
+ testnet: exchange.testnet === true,
173
+ }
174
+ : null;
175
+ await deps.runtime.reconnect(venue === 'hyperliquid'
176
+ ? { mode: deps.runtime.mode, exchange: null, venue, hlCredentials }
177
+ : { mode: deps.runtime.mode, exchange }, { adapterDeps: deps.adapterDeps });
68
178
  reconnected = true;
69
179
  }
70
180
  return {
71
181
  ok: true,
72
182
  message: reconnected
73
183
  ? `Credentials updated and ${deps.runtime.mode} adapter reconnected`
74
- : `Credentials stored (mode is PAPER — run set_trading_mode SHADOW to activate)`,
184
+ : `Credentials stored (mode is PAPER — run set_trading_mode to activate)`,
75
185
  fingerprint,
76
186
  reconnected,
77
187
  mode: deps.runtime.mode,
78
188
  readiness: deps.runtime.adapter.readiness,
189
+ venue,
190
+ restartRequired: false,
79
191
  };
80
192
  }
@@ -1,6 +1,7 @@
1
1
  import type { PluginRuntime } from '../onboarding/runtime.js';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
3
  import type { TradingMode } from '../types.js';
4
+ import { type VenueId } from '../venues/registry.js';
4
5
  export interface SetTradingModeArgs {
5
6
  mode: TradingMode;
6
7
  /** Optional acknowledgment flag for going live. The skill layer will set
@@ -20,6 +21,12 @@ export interface SetTradingModeDeps {
20
21
  adapterDeps: {
21
22
  adapter: IExchangeAdapter;
22
23
  };
24
+ /** The venue this process booted with. When the CONFIG venue differs (the
25
+ * operator stored credentials for another venue but hasn't restarted), a
26
+ * live flip is refused — the paper feed/symbols/readiness stack is still
27
+ * wired to the boot venue and a mixed-venue runtime is not a valid state.
28
+ * Absent (legacy tests) → guard skipped. */
29
+ bootVenue?: VenueId;
23
30
  /** Override the config file path — tests use this. */
24
31
  configPath?: string;
25
32
  }
@@ -92,6 +92,21 @@ export async function setTradingModeTool(args, deps) {
92
92
  reason: 'config_read_error',
93
93
  };
94
94
  }
95
+ // Venue-change-pending guard: credentials for a NEW venue were stored but
96
+ // the gateway hasn't restarted, so the paper/readiness stack is still on
97
+ // the boot venue. Refuse the live flip rather than build a mixed stack.
98
+ if (deps.bootVenue && venue !== deps.bootVenue) {
99
+ recordModeTransition({ previousMode, targetMode: target, acknowledged, ok: false, reason: 'venue_restart_required' });
100
+ return {
101
+ ok: false,
102
+ message: `The configured venue (${venue}) differs from the venue this agent booted with (${deps.bootVenue}). ` +
103
+ `Restart your agent first (send /restart to your agent in chat, or restart the OpenClaw service), then go ${target}.`,
104
+ previousMode,
105
+ mode: previousMode,
106
+ readiness: deps.runtime.adapter.readiness,
107
+ reason: 'venue_restart_required',
108
+ };
109
+ }
95
110
  if (!exchange && !hlCredentials) {
96
111
  recordModeTransition({ previousMode, targetMode: target, acknowledged, ok: false, reason: 'missing_credentials' });
97
112
  return {
@@ -1,7 +1,14 @@
1
+ import { type VenueId } from '../venues/registry.js';
1
2
  export interface TestExchangeCredentialsArgs {
2
- apiKey: string;
3
- secret: string;
3
+ /** Absent → 'binance' (legacy callers unchanged). */
4
+ venue?: string;
5
+ apiKey?: string;
6
+ secret?: string;
7
+ walletAddress?: string;
8
+ agentPrivateKey?: string;
4
9
  testnet?: boolean;
10
+ /** End-to-end encrypted envelope — sole source of fields when present. */
11
+ sealed?: unknown;
5
12
  }
6
13
  export interface TestExchangeCredentialsResult {
7
14
  ok: boolean;
@@ -9,8 +16,28 @@ export interface TestExchangeCredentialsResult {
9
16
  fingerprint: string;
10
17
  canReadBalance: boolean;
11
18
  canReadPositions: boolean;
19
+ /** Account balance in the venue's quote currency (USDT on Binance, USDC on
20
+ * Hyperliquid — perp equity + spot USDC). Field name is historical. */
12
21
  balanceUSDT: number | null;
13
22
  testnet: boolean;
14
23
  errors: string[];
24
+ /** Venue the credentials were tested against. */
25
+ venue?: VenueId;
26
+ /** HL only: the address the pasted agent key controls (public). */
27
+ agentAddress?: string;
28
+ /** HL only: true/false when extraAgents answered definitively; null when the
29
+ * approval list could not be checked (derivation or endpoint unavailable). */
30
+ agentApproved?: boolean | null;
31
+ /** HL only: approval expiry (epoch ms) when the exchange reports one. */
32
+ agentValidUntil?: number | null;
33
+ /** Non-fatal findings the dashboard should surface (expiring approval,
34
+ * unfunded account, unverifiable approval, …). */
35
+ warnings?: string[];
15
36
  }
16
- export declare function testExchangeCredentialsTool(args: TestExchangeCredentialsArgs): Promise<TestExchangeCredentialsResult>;
37
+ /** Injectable for tests; production uses global fetch. */
38
+ export interface TestExchangeCredentialsDeps {
39
+ fetchImpl?: typeof fetch;
40
+ /** Override the transport-key directory (sealed envelopes) — tests use this. */
41
+ transportKeyDir?: string;
42
+ }
43
+ export declare function testExchangeCredentialsTool(rawArgs: TestExchangeCredentialsArgs, deps?: TestExchangeCredentialsDeps): Promise<TestExchangeCredentialsResult>;
@@ -1,21 +1,38 @@
1
1
  // test_exchange_credentials — operator-only plugin tool.
2
2
  //
3
- // Takes a candidate Binance API key + secret and verifies them with a
4
- // transient, read-only call to the exchange (fetchBalance via
5
- // validatePermissions). Nothing is written to disk. Nothing is retained
6
- // after the call returns. This is the "pre-flight" check that lets the
7
- // dashboard say "yes these keys work, you can now click Store" before
8
- // the operator commits the credentials via set_exchange_credentials.
3
+ // Pre-flight verification of candidate credentials, per VENUE. Nothing is
4
+ // written to disk; nothing is retained after the call returns. This is what
5
+ // lets the dashboard say "yes these credentials work, you can now click Store"
6
+ // before the operator commits them via set_exchange_credentials.
7
+ //
8
+ // binance (default when `venue` is absent legacy callers byte-identical):
9
+ // transient BinancePrivateApi, read-only validatePermissions + fetchBalance.
10
+ // hyperliquid: entirely UNSIGNED verification (HL /info reads are keyless):
11
+ // 1. derive the address the pasted agent key controls — if it equals the
12
+ // master walletAddress the user pasted their MASTER key: hard refuse.
13
+ // 2. clearinghouseState(master) → the account's perp equity (+ spot USDC
14
+ // via spotClearinghouseState — the unified account collateralizes perps
15
+ // from spot, verified live on testnet 2026-07-12).
16
+ // 3. extraAgents(master) → is the derived agent address APPROVED, and when
17
+ // does the approval expire (named agents carry validUntil, max 180d —
18
+ // plan §3.2)? extraAgents shape verified against Chainstack/QuickNode
19
+ // HL API references 2026-08-01: [{ name, address, validUntil(ms) }].
20
+ // Endpoint failures degrade to warnings (fail-open on infra); a definitive
21
+ // "agent not in the approved list" is a hard fail with guidance.
9
22
  //
10
23
  // Security:
11
- // - Builds a throwaway BinancePrivateApi instance scoped to one call.
12
- // - Never logs the secret — only a redacted fingerprint.
24
+ // - Never logs the secret / agent key only a redacted fingerprint (the
25
+ // PUBLIC master address for HL).
13
26
  // - Does not touch plugin-config.json at all.
14
27
  import { BinancePrivateApi } from '../ccxt/binance-private.js';
15
28
  import { redactCredential } from '../config/plugin-config-io.js';
29
+ import { deriveAddressFromPrivateKey, isHexAddress, isHexPrivateKey, normalizeHexPrivateKey, sameAddress, } from '../venues/hyperliquid/hl-agent-wallet.js';
30
+ import { hlPreflight } from '../venues/hyperliquid/hl-preflight.js';
31
+ import { parseVenue } from '../venues/registry.js';
32
+ import { resolveSealedArgs } from './set-exchange-credentials.js';
16
33
  import { logger, formatError } from '../logger.js';
17
34
  const TAG = 'test-exchange-credentials';
18
- function validate(args) {
35
+ function validateBinance(args) {
19
36
  if (typeof args.apiKey !== 'string' || args.apiKey.trim().length < 8) {
20
37
  return 'apiKey must be a string of at least 8 characters';
21
38
  }
@@ -24,43 +41,49 @@ function validate(args) {
24
41
  }
25
42
  return null;
26
43
  }
27
- export async function testExchangeCredentialsTool(args) {
28
- const fingerprint = redactCredential(args.apiKey);
29
- const err = validate(args);
44
+ function baseResult(overrides, testnet) {
45
+ return {
46
+ canReadBalance: false,
47
+ canReadPositions: false,
48
+ balanceUSDT: null,
49
+ testnet,
50
+ errors: overrides.ok ? [] : [overrides.message],
51
+ ...overrides,
52
+ };
53
+ }
54
+ export async function testExchangeCredentialsTool(rawArgs, deps = {}) {
55
+ const resolved = resolveSealedArgs(rawArgs, deps.transportKeyDir);
56
+ if ('error' in resolved) {
57
+ return baseResult({ ok: false, message: resolved.error, fingerprint: '(sealed)' }, rawArgs?.testnet === true);
58
+ }
59
+ const args = resolved.args;
60
+ const venue = parseVenue(args.venue).venue;
61
+ if (venue === 'hyperliquid') {
62
+ return testHyperliquid(args, deps);
63
+ }
64
+ return testBinance(args);
65
+ }
66
+ // ---- Binance arm (unchanged behavior) ----
67
+ async function testBinance(args) {
68
+ const fingerprint = redactCredential(typeof args.apiKey === 'string' ? args.apiKey : '');
69
+ const testnet = args.testnet ?? false;
70
+ const err = validateBinance(args);
30
71
  if (err) {
31
- return {
32
- ok: false,
33
- message: err,
34
- fingerprint,
35
- canReadBalance: false,
36
- canReadPositions: false,
37
- balanceUSDT: null,
38
- testnet: args.testnet ?? false,
39
- errors: [err],
40
- };
72
+ return baseResult({ ok: false, message: err, fingerprint, venue: 'binance' }, testnet);
41
73
  }
42
74
  const config = {
43
75
  apiKey: args.apiKey.trim(),
44
76
  secret: args.secret.trim(),
45
- testnet: args.testnet ?? false,
77
+ testnet,
46
78
  };
47
- logger.info(TAG, `Testing credentials (fingerprint: ${fingerprint}, testnet: ${config.testnet})`);
79
+ logger.info(TAG, `Testing binance credentials (fingerprint: ${fingerprint}, testnet: ${testnet})`);
48
80
  let api;
49
81
  try {
50
82
  api = new BinancePrivateApi(config);
51
83
  }
52
84
  catch (e) {
53
85
  const msg = formatError(e);
54
- return {
55
- ok: false,
56
- message: `Could not initialize exchange client: ${msg}`,
57
- fingerprint,
58
- canReadBalance: false,
59
- canReadPositions: false,
60
- balanceUSDT: null,
61
- testnet: config.testnet ?? false,
62
- errors: [msg],
63
- };
86
+ return baseResult({ ok: false, message: `Could not initialize exchange client: ${msg}`, fingerprint, errors: [msg], venue: 'binance' }, testnet);
64
87
  }
65
88
  const perms = await api.validatePermissions();
66
89
  // Opportunistically read the balance for the result payload — if
@@ -94,7 +117,122 @@ export async function testExchangeCredentialsTool(args) {
94
117
  canReadBalance: perms.canReadBalance,
95
118
  canReadPositions: perms.canReadPositions,
96
119
  balanceUSDT,
97
- testnet: config.testnet ?? false,
120
+ testnet,
98
121
  errors: perms.errors,
122
+ venue: 'binance',
123
+ };
124
+ }
125
+ // ---- Hyperliquid arm ----
126
+ async function testHyperliquid(args, deps) {
127
+ const testnet = args.testnet ?? false;
128
+ const fingerprint = isHexAddress(args.walletAddress)
129
+ ? redactCredential(args.walletAddress.trim())
130
+ : '(not-set)';
131
+ if (!isHexAddress(args.walletAddress)) {
132
+ return baseResult({
133
+ ok: false,
134
+ message: 'walletAddress must be your MASTER account address: 0x followed by 40 hex characters',
135
+ fingerprint,
136
+ venue: 'hyperliquid',
137
+ }, testnet);
138
+ }
139
+ if (!isHexPrivateKey(args.agentPrivateKey)) {
140
+ return baseResult({
141
+ ok: false,
142
+ message: 'agentPrivateKey must be an agent (API) wallet private key: 64 hex characters (0x prefix optional)',
143
+ fingerprint,
144
+ venue: 'hyperliquid',
145
+ }, testnet);
146
+ }
147
+ const walletAddress = args.walletAddress.trim();
148
+ const agentPrivateKey = normalizeHexPrivateKey(args.agentPrivateKey);
149
+ const warnings = [];
150
+ logger.info(TAG, `Testing hyperliquid credentials (master: ${fingerprint}, testnet: ${testnet})`);
151
+ // 1. Master-key-pasted refusal + agent address for the approval check.
152
+ let agentAddress;
153
+ const derived = await deriveAddressFromPrivateKey(agentPrivateKey);
154
+ if (derived.ok) {
155
+ if (sameAddress(derived.address, walletAddress)) {
156
+ return baseResult({
157
+ ok: false,
158
+ message: 'REFUSED: that private key controls the master wallet address you entered — you pasted your ' +
159
+ 'MASTER wallet key, which can withdraw funds. Never share it. In the Hyperliquid app open ' +
160
+ 'More → API, generate an agent wallet, approve it, and paste the AGENT key instead.',
161
+ fingerprint,
162
+ venue: 'hyperliquid',
163
+ }, testnet);
164
+ }
165
+ agentAddress = derived.address;
166
+ }
167
+ else if (derived.reason === 'invalid_key') {
168
+ return baseResult({
169
+ ok: false,
170
+ message: 'agentPrivateKey is not a valid secp256k1 private key — re-copy it from the Hyperliquid API page',
171
+ fingerprint,
172
+ venue: 'hyperliquid',
173
+ }, testnet);
174
+ }
175
+ else {
176
+ warnings.push('Could not derive the agent wallet address on this host — approval not verified.');
177
+ }
178
+ // 2+3. Master account state + agent approval — the shared unsigned preflight
179
+ // (hl-preflight.ts, also used by hl_agent_wallet_status).
180
+ const pre = await hlPreflight({
181
+ walletAddress,
182
+ agentAddress: agentAddress ?? null,
183
+ testnet,
184
+ fetchImpl: deps.fetchImpl,
185
+ });
186
+ warnings.push(...pre.warnings);
187
+ if (!pre.reachable) {
188
+ const msg = pre.unreachableError ?? 'unknown error';
189
+ return baseResult({
190
+ ok: false,
191
+ message: `Could not reach Hyperliquid to verify the master account: ${msg}`,
192
+ fingerprint,
193
+ venue: 'hyperliquid',
194
+ agentAddress,
195
+ errors: [msg],
196
+ }, testnet);
197
+ }
198
+ const canReadBalance = pre.balanceUSDC != null;
199
+ const balanceUSDC = pre.balanceUSDC;
200
+ const agentApproved = pre.agentApproved;
201
+ const agentValidUntil = pre.agentValidUntil;
202
+ if (agentApproved === false) {
203
+ const msg = 'This agent key is NOT approved for the master account (or the approval expired). ' +
204
+ 'In the Hyperliquid app open More → API and approve the agent wallet, then test again.';
205
+ return {
206
+ ok: false,
207
+ message: msg,
208
+ fingerprint,
209
+ canReadBalance,
210
+ canReadPositions: canReadBalance,
211
+ balanceUSDT: balanceUSDC,
212
+ testnet,
213
+ errors: [msg],
214
+ venue: 'hyperliquid',
215
+ agentAddress,
216
+ agentApproved,
217
+ agentValidUntil,
218
+ warnings,
219
+ };
220
+ }
221
+ const balanceText = balanceUSDC != null ? balanceUSDC.toFixed(2) : 'unknown';
222
+ const approvalText = agentApproved === true ? 'agent wallet approved' : 'agent approval not verified';
223
+ return {
224
+ ok: true,
225
+ message: `Connection verified — USDC balance: ${balanceText}, ${approvalText}`,
226
+ fingerprint,
227
+ canReadBalance,
228
+ canReadPositions: canReadBalance,
229
+ balanceUSDT: balanceUSDC,
230
+ testnet,
231
+ errors: [],
232
+ venue: 'hyperliquid',
233
+ agentAddress,
234
+ agentApproved,
235
+ agentValidUntil,
236
+ warnings,
99
237
  };
100
238
  }
package/types.d.ts CHANGED
@@ -111,23 +111,22 @@ export declare const DEFAULT_CONFIG: PluginConfig;
111
111
  * `exchange` block (openclaw.json legacy fallback). Which fields matter
112
112
  * depends on `venue` (docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.2):
113
113
  *
114
- * binance (default, and the only live-supported venue until Phase 3) —
115
- * apiKey + secret (HMAC pair) are required; walletAddress/agentPrivateKey
116
- * are ignored.
114
+ * binance (default) apiKey + secret (HMAC pair) are required;
115
+ * walletAddress/agentPrivateKey are ignored.
117
116
  * hyperliquid — walletAddress (MASTER wallet address, 0x…, used for
118
117
  * queries; never its private key) + agentPrivateKey (an approved
119
118
  * agent/API wallet's key — signs orders, cannot withdraw). apiKey/secret
120
- * are meaningless. Boot falls back to PAPER for this venue until the
121
- * Phase 3 adapter ships.
119
+ * are absent an HL block never carries a Binance HMAC pair.
122
120
  *
123
- * apiKey/secret stay required at the type level because every constructed
124
- * instance today feeds BinancePrivateApi; the on-disk JSON is parsed, not
125
- * type-constructed, so an HL block without them is readable. Phase 3
126
- * restructures this into a per-venue discriminated union. */
121
+ * apiKey/secret are optional at the type level because the on-disk shape is
122
+ * per-venue: every Binance consumer (index.ts boot, set_trading_mode,
123
+ * set_exchange_credentials) truthiness-guards the pair before constructing a
124
+ * BinancePrivateApi, so an HL block without them is both readable AND
125
+ * writable. */
127
126
  export interface ExchangeConfig {
128
127
  venue?: 'binance' | 'hyperliquid';
129
- apiKey: string;
130
- secret: string;
128
+ apiKey?: string;
129
+ secret?: string;
131
130
  testnet?: boolean;
132
131
  walletAddress?: string;
133
132
  agentPrivateKey?: string;
@@ -0,0 +1,29 @@
1
+ /** Generate a fresh secp256k1 private key (0x-prefixed, 64 hex chars) from
2
+ * CSPRNG bytes, rejection-sampled into the valid scalar range. This is how
3
+ * the box mints its own HL agent wallet — the key is BORN here and never
4
+ * leaves the machine (the caller persists it to plugin-config and surfaces
5
+ * only the derived PUBLIC address). */
6
+ export declare function generateAgentPrivateKey(): string;
7
+ /** Master/agent wallet address: 0x + 40 hex chars. */
8
+ export declare function isHexAddress(value: unknown): value is string;
9
+ /** Agent private key: 32 bytes of hex, 0x prefix optional (the HL app shows it
10
+ * with the prefix; some wallets export without). */
11
+ export declare function isHexPrivateKey(value: unknown): value is string;
12
+ /** Canonical stored form: trimmed, 0x-prefixed, original casing preserved. */
13
+ export declare function normalizeHexPrivateKey(value: string): string;
14
+ export type DeriveAddressResult = {
15
+ ok: true;
16
+ address: string;
17
+ } | {
18
+ ok: false;
19
+ reason: 'invalid_key' | 'unavailable';
20
+ };
21
+ /** Test seam: reset the memoized keccak loader (unit tests only). */
22
+ export declare function __resetKeccakCacheForTests(): void;
23
+ /** Derive the Ethereum address a private key controls (lowercase 0x…40 hex).
24
+ * 'invalid_key' = the key is malformed or outside the secp256k1 range;
25
+ * 'unavailable' = the keccak dependency could not be loaded (callers must
26
+ * fail OPEN — skip the check, never block credential entry on infra). */
27
+ export declare function deriveAddressFromPrivateKey(privateKey: string): Promise<DeriveAddressResult>;
28
+ /** Case-insensitive address equality (addresses may arrive checksummed). */
29
+ export declare function sameAddress(a: string, b: string): boolean;