@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.
- package/bridge/bridge.d.ts +10 -0
- package/bridge/bridge.js +142 -22
- package/bridge/gateway/gateway-ws-client.d.ts +7 -1
- package/bridge/gateway/gateway-ws-client.js +31 -8
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +11 -0
- package/bridge/provider.d.ts +73 -6
- package/bridge/providers/gateway.d.ts +12 -3
- package/bridge/providers/gateway.js +18 -5
- package/bridge/providers/onboarding-commands.d.ts +33 -7
- package/bridge/providers/onboarding-commands.js +109 -13
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +6 -0
- package/config/tool-gate.js +4 -0
- package/index.js +77 -12
- package/ingest/position-auto-capture.js +19 -0
- package/ingest/readiness-reporter.js +25 -1
- package/openclaw.plugin.json +3 -1
- package/package.json +38 -38
- 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 +35 -0
- package/tools/hl-provision-agent-wallet.js +144 -0
- package/tools/set-exchange-credentials.d.ts +32 -3
- package/tools/set-exchange-credentials.js +144 -32
- 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 +10 -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
|
@@ -1,19 +1,66 @@
|
|
|
1
1
|
// set_exchange_credentials — operator-only plugin tool.
|
|
2
2
|
//
|
|
3
|
-
// Writes
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
// -
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
54
|
-
message: `
|
|
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
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
// -
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
77
|
+
testnet,
|
|
46
78
|
};
|
|
47
|
-
logger.info(TAG, `Testing credentials (fingerprint: ${fingerprint}, 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
|
|
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
|
|
115
|
-
*
|
|
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
|
|
121
|
-
* Phase 3 adapter ships.
|
|
119
|
+
* are absent — an HL block never carries a Binance HMAC pair.
|
|
122
120
|
*
|
|
123
|
-
* apiKey/secret
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
130
|
-
secret
|
|
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;
|