@reefclaw/openclaw-plugin 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/bridge.js +80 -18
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +4 -0
- package/bridge/provider.d.ts +37 -4
- package/bridge/providers/gateway.d.ts +12 -1
- package/bridge/providers/gateway.js +5 -1
- package/bridge/providers/onboarding-commands.d.ts +17 -1
- package/bridge/providers/onboarding-commands.js +32 -3
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +2 -0
- package/config/plugin-config-io.d.ts +26 -0
- package/config/plugin-config-io.js +47 -0
- package/config/tool-gate.js +1 -0
- package/index.js +27 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
- package/tools/hl-provision-agent-wallet.d.ts +9 -1
- package/tools/hl-provision-agent-wallet.js +56 -9
- package/tools/hl-submit-agent-approval.d.ts +27 -0
- package/tools/hl-submit-agent-approval.js +140 -0
- package/tools/set-exchange-credentials.d.ts +10 -0
- package/tools/set-exchange-credentials.js +81 -12
- package/types.d.ts +6 -0
package/bridge/bridge.js
CHANGED
|
@@ -505,6 +505,44 @@ export class Bridge {
|
|
|
505
505
|
}
|
|
506
506
|
return;
|
|
507
507
|
}
|
|
508
|
+
if (method === 'hl_submit_agent_approval') {
|
|
509
|
+
const provider = this.provider;
|
|
510
|
+
if (!provider.submitHlAgentApproval) {
|
|
511
|
+
this.connector.sendResponse(id, true, {
|
|
512
|
+
ok: false,
|
|
513
|
+
message: 'Provider does not support hl_submit_agent_approval (likely mock provider)',
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const action = params?.action;
|
|
518
|
+
const nonce = params?.nonce;
|
|
519
|
+
const signature = params?.signature;
|
|
520
|
+
if (!action || typeof action !== 'object' ||
|
|
521
|
+
typeof nonce !== 'number' ||
|
|
522
|
+
!signature || typeof signature.r !== 'string' || typeof signature.s !== 'string' ||
|
|
523
|
+
typeof signature.v !== 'number') {
|
|
524
|
+
this.connector.sendResponse(id, false, undefined, {
|
|
525
|
+
code: 400,
|
|
526
|
+
message: 'hl_submit_agent_approval requires { action, nonce, signature:{r,s,v} }',
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// The plugin re-validates strictly (approveAgent only, own agent
|
|
531
|
+
// address only) — this layer just shape-checks and forwards.
|
|
532
|
+
audit('hl_submit_agent_approval.start', {
|
|
533
|
+
id,
|
|
534
|
+
chain: action.hyperliquidChain,
|
|
535
|
+
agentAddress: action.agentAddress,
|
|
536
|
+
});
|
|
537
|
+
const outcome = await provider.submitHlAgentApproval({
|
|
538
|
+
action: action,
|
|
539
|
+
nonce,
|
|
540
|
+
signature: { r: signature.r, s: signature.s, v: signature.v },
|
|
541
|
+
});
|
|
542
|
+
audit('hl_submit_agent_approval.complete', { id, ok: outcome.ok, hlStatus: outcome.hlStatus });
|
|
543
|
+
this.connector.sendResponse(id, true, outcome);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
508
546
|
if (method === 'hl_agent_wallet_status') {
|
|
509
547
|
const provider = this.provider;
|
|
510
548
|
if (!provider.getHlAgentWalletStatus) {
|
|
@@ -712,7 +750,10 @@ export class Bridge {
|
|
|
712
750
|
}
|
|
713
751
|
const kid = sealed.kid;
|
|
714
752
|
return {
|
|
715
|
-
req: {
|
|
753
|
+
req: {
|
|
754
|
+
sealed: sealed,
|
|
755
|
+
...(params?.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
|
|
756
|
+
},
|
|
716
757
|
fp: `sealed:${typeof kid === 'string' ? kid.slice(0, 16) : 'unknown'}`,
|
|
717
758
|
};
|
|
718
759
|
}
|
|
@@ -720,35 +761,55 @@ export class Bridge {
|
|
|
720
761
|
if (params?.venue === 'hyperliquid') {
|
|
721
762
|
const walletAddress = typeof params?.walletAddress === 'string' ? params.walletAddress.trim() : '';
|
|
722
763
|
const agentPrivateKey = typeof params?.agentPrivateKey === 'string' ? params.agentPrivateKey.trim() : '';
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
764
|
+
// Activation-only (both fields absent) = "switch back to Hyperliquid,
|
|
765
|
+
// my credentials are already stored" — the plugin resolves it against
|
|
766
|
+
// what is on disk and errors if nothing is stored.
|
|
767
|
+
const activationOnly = walletAddress === '' && agentPrivateKey === '';
|
|
768
|
+
if (!activationOnly) {
|
|
769
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
|
|
770
|
+
this.connector.sendResponse(id, false, undefined, {
|
|
771
|
+
code: 400,
|
|
772
|
+
message: 'walletAddress must be your MASTER account address: 0x followed by 40 hex characters',
|
|
773
|
+
});
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
if (!/^(0x)?[0-9a-fA-F]{64}$/.test(agentPrivateKey)) {
|
|
777
|
+
this.connector.sendResponse(id, false, undefined, {
|
|
778
|
+
code: 400,
|
|
779
|
+
message: 'agentPrivateKey must be an agent (API) wallet private key: 64 hex characters (0x prefix optional)',
|
|
780
|
+
});
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
736
783
|
}
|
|
737
784
|
return {
|
|
738
|
-
req: {
|
|
739
|
-
|
|
785
|
+
req: {
|
|
786
|
+
venue: 'hyperliquid',
|
|
787
|
+
...(activationOnly ? {} : { walletAddress, agentPrivateKey }),
|
|
788
|
+
testnet,
|
|
789
|
+
...(params?.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
|
|
790
|
+
},
|
|
791
|
+
fp: activationOnly ? '(stored)' : fingerprint(walletAddress),
|
|
740
792
|
};
|
|
741
793
|
}
|
|
742
794
|
const apiKey = typeof params?.apiKey === 'string' ? params.apiKey : '';
|
|
743
795
|
const secret = typeof params?.secret === 'string' ? params.secret : '';
|
|
744
|
-
|
|
796
|
+
const activationOnly = apiKey === '' && secret === '' && params?.venue === 'binance';
|
|
797
|
+
if (!activationOnly && (apiKey.length < 8 || secret.length < 8)) {
|
|
745
798
|
this.connector.sendResponse(id, false, undefined, {
|
|
746
799
|
code: 400,
|
|
747
800
|
message: 'apiKey and secret must be at least 8 characters each',
|
|
748
801
|
});
|
|
749
802
|
return null;
|
|
750
803
|
}
|
|
751
|
-
return {
|
|
804
|
+
return {
|
|
805
|
+
req: {
|
|
806
|
+
venue: 'binance',
|
|
807
|
+
...(activationOnly ? {} : { apiKey, secret }),
|
|
808
|
+
testnet,
|
|
809
|
+
...(params?.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
|
|
810
|
+
},
|
|
811
|
+
fp: activationOnly ? '(stored)' : fingerprint(apiKey),
|
|
812
|
+
};
|
|
752
813
|
}
|
|
753
814
|
/** Handle a set_exchange_credentials request. The secret value passes
|
|
754
815
|
* through once and is never cached or logged; only a redacted fingerprint
|
|
@@ -858,6 +919,7 @@ export class Bridge {
|
|
|
858
919
|
walletAddress,
|
|
859
920
|
testnet: params?.testnet === true,
|
|
860
921
|
regenerate: params?.regenerate === true,
|
|
922
|
+
confirm_venue_switch: params?.confirm_venue_switch === true,
|
|
861
923
|
});
|
|
862
924
|
audit('hl_provision_agent_wallet.complete', {
|
|
863
925
|
id,
|
|
@@ -3,7 +3,7 @@ import { GatewayHttpClient, GatewayHttpError } from './gateway-http-client.js';
|
|
|
3
3
|
* Logical tool names used by ReefClaw internally.
|
|
4
4
|
* Each maps to one or more candidate actual tool names on the gateway.
|
|
5
5
|
*/
|
|
6
|
-
export type LogicalTool = 'fetch_ticker' | 'fetch_balance' | 'fetch_ohlcv' | 'fetch_positions' | 'fetch_open_orders' | 'cancel_all_orders' | 'cancel_order' | 'close_position' | 'create_order' | 'get_market_structure' | 'get_crypto_metrics' | 'get_market_intel' | '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';
|
|
6
|
+
export type LogicalTool = 'fetch_ticker' | 'fetch_balance' | 'fetch_ohlcv' | 'fetch_positions' | 'fetch_open_orders' | 'cancel_all_orders' | 'cancel_order' | 'close_position' | 'create_order' | 'get_market_structure' | 'get_crypto_metrics' | 'get_market_intel' | 'set_trading_mode' | 'set_exchange_credentials' | 'test_exchange_credentials' | 'clear_exchange_credentials' | 'hl_provision_agent_wallet' | 'hl_agent_wallet_status' | 'hl_submit_agent_approval' | 'get_bracket_config' | 'set_bracket_requirement';
|
|
7
7
|
/**
|
|
8
8
|
* Build the test args used when probing each logical tool.
|
|
9
9
|
*
|
|
@@ -22,6 +22,7 @@ const OPTIONAL_TOOLS = [
|
|
|
22
22
|
'clear_exchange_credentials',
|
|
23
23
|
'hl_provision_agent_wallet',
|
|
24
24
|
'hl_agent_wallet_status',
|
|
25
|
+
'hl_submit_agent_approval',
|
|
25
26
|
'get_bracket_config',
|
|
26
27
|
'set_bracket_requirement',
|
|
27
28
|
];
|
|
@@ -48,6 +49,7 @@ const CANDIDATES = {
|
|
|
48
49
|
clear_exchange_credentials: ['clear_exchange_credentials'],
|
|
49
50
|
hl_provision_agent_wallet: ['hl_provision_agent_wallet'],
|
|
50
51
|
hl_agent_wallet_status: ['hl_agent_wallet_status'],
|
|
52
|
+
hl_submit_agent_approval: ['hl_submit_agent_approval'],
|
|
51
53
|
get_bracket_config: ['get_bracket_config'],
|
|
52
54
|
set_bracket_requirement: ['set_bracket_requirement'],
|
|
53
55
|
};
|
|
@@ -119,6 +121,8 @@ export function buildProbeArgs(symbol = DEFAULT_PROBE_SYMBOL) {
|
|
|
119
121
|
// timeout and mark the tool missing forever).
|
|
120
122
|
hl_provision_agent_wallet: { walletAddress: '' },
|
|
121
123
|
hl_agent_wallet_status: { probe: true },
|
|
124
|
+
// Same dedicated probe path: a real submit would POST to Hyperliquid.
|
|
125
|
+
hl_submit_agent_approval: { probe: true },
|
|
122
126
|
// Bracket-config probes: no-arg read; set with an invalid flag triggers early-return.
|
|
123
127
|
get_bracket_config: {},
|
|
124
128
|
set_bracket_requirement: { flag: '__probe__', value: false },
|
package/bridge/provider.d.ts
CHANGED
|
@@ -9,16 +9,19 @@ import type { TradingMode } from '@reefclaw/shared';
|
|
|
9
9
|
* decryption. A discriminated union so a caller can never mix the shapes. */
|
|
10
10
|
export type ExchangeCredentialsRequest = {
|
|
11
11
|
venue: 'binance';
|
|
12
|
-
apiKey
|
|
13
|
-
secret
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
secret?: string;
|
|
14
14
|
testnet?: boolean;
|
|
15
|
+
confirm_venue_switch?: boolean;
|
|
15
16
|
} | {
|
|
16
17
|
venue: 'hyperliquid';
|
|
17
|
-
walletAddress
|
|
18
|
-
agentPrivateKey
|
|
18
|
+
walletAddress?: string;
|
|
19
|
+
agentPrivateKey?: string;
|
|
19
20
|
testnet?: boolean;
|
|
21
|
+
confirm_venue_switch?: boolean;
|
|
20
22
|
} | {
|
|
21
23
|
sealed: Record<string, unknown>;
|
|
24
|
+
confirm_venue_switch?: boolean;
|
|
22
25
|
};
|
|
23
26
|
/** Result of hl_provision_agent_wallet — the box-generated agent wallet. */
|
|
24
27
|
export interface HlProvisionOutcome {
|
|
@@ -32,6 +35,18 @@ export interface HlProvisionOutcome {
|
|
|
32
35
|
masterMatches?: boolean;
|
|
33
36
|
restartRequired?: boolean;
|
|
34
37
|
mode: string;
|
|
38
|
+
requiresVenueSwitchConfirm?: boolean;
|
|
39
|
+
currentVenue?: string;
|
|
40
|
+
currentMode?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Result of hl_submit_agent_approval — the box relays the wallet-signed
|
|
43
|
+
* approveAgent action to Hyperliquid (browsers are often blocked). */
|
|
44
|
+
export interface HlSubmitApprovalOutcome {
|
|
45
|
+
ok: boolean;
|
|
46
|
+
message: string;
|
|
47
|
+
hlStatus?: string;
|
|
48
|
+
chain?: string;
|
|
49
|
+
agentAddress?: string;
|
|
35
50
|
}
|
|
36
51
|
/** Result of hl_agent_wallet_status — approval polling for the guided flow. */
|
|
37
52
|
export interface HlAgentWalletStatusOutcome {
|
|
@@ -156,6 +171,11 @@ export interface OpenClawProvider {
|
|
|
156
171
|
/** True when the stored venue differs from the booted venue — the
|
|
157
172
|
* operator must restart the agent before the change takes effect. */
|
|
158
173
|
restartRequired?: boolean;
|
|
174
|
+
/** ok:false + this ⇒ the venue switch needs acknowledgement; retry with
|
|
175
|
+
* confirm_venue_switch:true. */
|
|
176
|
+
requiresVenueSwitchConfirm?: boolean;
|
|
177
|
+
currentVenue?: string;
|
|
178
|
+
currentMode?: string;
|
|
159
179
|
}>;
|
|
160
180
|
/** Operator-only: verify exchange credentials with transient read-only
|
|
161
181
|
* calls, per venue. Nothing is persisted. */
|
|
@@ -180,10 +200,23 @@ export interface OpenClawProvider {
|
|
|
180
200
|
walletAddress: string;
|
|
181
201
|
testnet?: boolean;
|
|
182
202
|
regenerate?: boolean;
|
|
203
|
+
confirm_venue_switch?: boolean;
|
|
183
204
|
}): Promise<HlProvisionOutcome>;
|
|
184
205
|
/** Operator-only: approval/balance status of the box's provisioned HL agent
|
|
185
206
|
* wallet (polled by the dashboard's guided flow after the wallet signature). */
|
|
186
207
|
getHlAgentWalletStatus?(): Promise<HlAgentWalletStatusOutcome>;
|
|
208
|
+
/** Operator-only: submit the wallet-signed approveAgent action to
|
|
209
|
+
* Hyperliquid FROM THE BOX. Carries no secret — only a signature the
|
|
210
|
+
* operator's wallet already produced. */
|
|
211
|
+
submitHlAgentApproval?(args: {
|
|
212
|
+
action: Record<string, unknown>;
|
|
213
|
+
nonce: number;
|
|
214
|
+
signature: {
|
|
215
|
+
r: string;
|
|
216
|
+
s: string;
|
|
217
|
+
v: number;
|
|
218
|
+
};
|
|
219
|
+
}): Promise<HlSubmitApprovalOutcome>;
|
|
187
220
|
/** Operator-only: remove stored credentials from the plugin config and
|
|
188
221
|
* de-escalate to PAPER mode if currently running in a live mode. */
|
|
189
222
|
clearExchangeCredentials?(): Promise<{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawProvider, ProviderEvents, ProviderEventName, ExchangeCredentialsRequest, HlProvisionOutcome, HlAgentWalletStatusOutcome } from '../provider.js';
|
|
1
|
+
import type { OpenClawProvider, ProviderEvents, ProviderEventName, ExchangeCredentialsRequest, HlProvisionOutcome, HlAgentWalletStatusOutcome, HlSubmitApprovalOutcome } from '../provider.js';
|
|
2
2
|
import type { EmergencyAction, ReconciliationSnapshot, TradingMode } from '../types.js';
|
|
3
3
|
import type { GatewayConfig } from '../gateway/gateway-config.js';
|
|
4
4
|
import { type GetBracketConfigOutcome, type SetBracketRequirementOutcome, type BracketRequirementFlag, type SetTradingModeOutcome, type SetExchangeCredentialsOutcome, type TestExchangeCredentialsOutcome, type ClearExchangeCredentialsOutcome } from './onboarding-commands.js';
|
|
@@ -227,7 +227,18 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
227
227
|
walletAddress: string;
|
|
228
228
|
testnet?: boolean;
|
|
229
229
|
regenerate?: boolean;
|
|
230
|
+
confirm_venue_switch?: boolean;
|
|
230
231
|
}): Promise<HlProvisionOutcome>;
|
|
232
|
+
/** Operator-only. Relay the wallet-signed approveAgent action from the box. */
|
|
233
|
+
submitHlAgentApproval(args: {
|
|
234
|
+
action: Record<string, unknown>;
|
|
235
|
+
nonce: number;
|
|
236
|
+
signature: {
|
|
237
|
+
r: string;
|
|
238
|
+
s: string;
|
|
239
|
+
v: number;
|
|
240
|
+
};
|
|
241
|
+
}): Promise<HlSubmitApprovalOutcome>;
|
|
231
242
|
/** Operator-only. Approval/balance status of the provisioned HL wallet. */
|
|
232
243
|
getHlAgentWalletStatus(): Promise<HlAgentWalletStatusOutcome>;
|
|
233
244
|
/** Operator-only. Read the current bracket-orders config from the plugin. */
|
|
@@ -17,7 +17,7 @@ import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-c
|
|
|
17
17
|
import { parseIdentityName } from '../utils/identity-name.js';
|
|
18
18
|
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, DRAWDOWN_ZONE_THRESHOLDS, TENANT_LIMIT_BOUNDS, boundedNum, validateDrawdownLadder, } from './risk-calculator.js';
|
|
19
19
|
import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
|
|
20
|
-
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, executeProvisionHlAgentWallet, executeHlAgentWalletStatus, } from './onboarding-commands.js';
|
|
20
|
+
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, executeProvisionHlAgentWallet, executeHlAgentWalletStatus, executeSubmitHlAgentApproval, } from './onboarding-commands.js';
|
|
21
21
|
const TAG = 'gateway';
|
|
22
22
|
// ---- Day-start NAV persistence ----
|
|
23
23
|
// Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
|
|
@@ -705,6 +705,10 @@ export class GatewayProvider {
|
|
|
705
705
|
async provisionHlAgentWallet(args) {
|
|
706
706
|
return executeProvisionHlAgentWallet({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, args);
|
|
707
707
|
}
|
|
708
|
+
/** Operator-only. Relay the wallet-signed approveAgent action from the box. */
|
|
709
|
+
async submitHlAgentApproval(args) {
|
|
710
|
+
return executeSubmitHlAgentApproval({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, args);
|
|
711
|
+
}
|
|
708
712
|
/** Operator-only. Approval/balance status of the provisioned HL wallet. */
|
|
709
713
|
async getHlAgentWalletStatus() {
|
|
710
714
|
return executeHlAgentWalletStatus({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GatewayHttpClient } from '../gateway/gateway-http-client.js';
|
|
2
2
|
import type { ToolMap } from '../gateway/tool-discovery.js';
|
|
3
|
-
import type { ExchangeCredentialsRequest, HlProvisionOutcome, HlAgentWalletStatusOutcome } from '../provider.js';
|
|
3
|
+
import type { ExchangeCredentialsRequest, HlProvisionOutcome, HlAgentWalletStatusOutcome, HlSubmitApprovalOutcome } from '../provider.js';
|
|
4
4
|
import type { TradingMode } from '@reefclaw/shared';
|
|
5
5
|
export interface SetTradingModeOutcome {
|
|
6
6
|
ok: boolean;
|
|
@@ -22,6 +22,10 @@ export interface SetExchangeCredentialsOutcome {
|
|
|
22
22
|
/** True when the stored venue differs from the booted venue — the operator
|
|
23
23
|
* must restart the agent before the change takes effect. */
|
|
24
24
|
restartRequired?: boolean;
|
|
25
|
+
/** ok:false + this ⇒ retry with confirm_venue_switch:true after warning. */
|
|
26
|
+
requiresVenueSwitchConfirm?: boolean;
|
|
27
|
+
currentVenue?: string;
|
|
28
|
+
currentMode?: string;
|
|
25
29
|
}
|
|
26
30
|
export interface TestExchangeCredentialsOutcome {
|
|
27
31
|
ok: boolean;
|
|
@@ -110,7 +114,19 @@ export declare function executeProvisionHlAgentWallet(ctx: OnboardingContext, ar
|
|
|
110
114
|
walletAddress: string;
|
|
111
115
|
testnet?: boolean;
|
|
112
116
|
regenerate?: boolean;
|
|
117
|
+
confirm_venue_switch?: boolean;
|
|
113
118
|
}): Promise<HlProvisionOutcome>;
|
|
119
|
+
/** Forward an hl_submit_agent_approval invocation to the plugin. The payload
|
|
120
|
+
* is a signature the operator's wallet produced — no secret material. */
|
|
121
|
+
export declare function executeSubmitHlAgentApproval(ctx: OnboardingContext, args: {
|
|
122
|
+
action: Record<string, unknown>;
|
|
123
|
+
nonce: number;
|
|
124
|
+
signature: {
|
|
125
|
+
r: string;
|
|
126
|
+
s: string;
|
|
127
|
+
v: number;
|
|
128
|
+
};
|
|
129
|
+
}): Promise<HlSubmitApprovalOutcome>;
|
|
114
130
|
/** Forward an hl_agent_wallet_status invocation to the plugin. Read-only. */
|
|
115
131
|
export declare function executeHlAgentWalletStatus(ctx: OnboardingContext): Promise<HlAgentWalletStatusOutcome>;
|
|
116
132
|
/** Forward a set_exchange_credentials invocation to the plugin via HTTP.
|
|
@@ -43,12 +43,13 @@ export async function executeSetTradingMode(ctx, mode, acknowledged) {
|
|
|
43
43
|
* exactly once; the second return value is what must be scrubbed from any
|
|
44
44
|
* error text before it can be logged. */
|
|
45
45
|
function credentialPayload(ctx, req) {
|
|
46
|
+
const confirm = req.confirm_venue_switch === true ? { confirm_venue_switch: true } : {};
|
|
46
47
|
if ('sealed' in req) {
|
|
47
48
|
// End-to-end encrypted envelope — forwarded opaquely; there is no
|
|
48
49
|
// plaintext here to scrub (the empty scrub token never matches anything:
|
|
49
50
|
// executeSet/Test guard on it before building a scrub regex).
|
|
50
51
|
return {
|
|
51
|
-
payload: { sealed: req.sealed, operator_token: ctx.operatorToken },
|
|
52
|
+
payload: { sealed: req.sealed, ...confirm, operator_token: ctx.operatorToken },
|
|
52
53
|
scrub: '',
|
|
53
54
|
};
|
|
54
55
|
}
|
|
@@ -59,9 +60,10 @@ function credentialPayload(ctx, req) {
|
|
|
59
60
|
walletAddress: req.walletAddress,
|
|
60
61
|
agentPrivateKey: req.agentPrivateKey,
|
|
61
62
|
testnet: req.testnet,
|
|
63
|
+
...confirm,
|
|
62
64
|
operator_token: ctx.operatorToken,
|
|
63
65
|
},
|
|
64
|
-
scrub: req.agentPrivateKey,
|
|
66
|
+
scrub: req.agentPrivateKey ?? '',
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
return {
|
|
@@ -70,9 +72,10 @@ function credentialPayload(ctx, req) {
|
|
|
70
72
|
apiKey: req.apiKey,
|
|
71
73
|
secret: req.secret,
|
|
72
74
|
testnet: req.testnet,
|
|
75
|
+
...confirm,
|
|
73
76
|
operator_token: ctx.operatorToken,
|
|
74
77
|
},
|
|
75
|
-
scrub: req.secret,
|
|
78
|
+
scrub: req.secret ?? '',
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
function scrubError(reason, scrub) {
|
|
@@ -240,6 +243,7 @@ export async function executeProvisionHlAgentWallet(ctx, args) {
|
|
|
240
243
|
walletAddress: args.walletAddress,
|
|
241
244
|
testnet: args.testnet,
|
|
242
245
|
regenerate: args.regenerate,
|
|
246
|
+
...(args.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
|
|
243
247
|
operator_token: ctx.operatorToken,
|
|
244
248
|
});
|
|
245
249
|
return result.data;
|
|
@@ -250,6 +254,31 @@ export async function executeProvisionHlAgentWallet(ctx, args) {
|
|
|
250
254
|
return { ok: false, message: reason, venue: 'hyperliquid', mode: 'PAPER' };
|
|
251
255
|
}
|
|
252
256
|
}
|
|
257
|
+
/** Forward an hl_submit_agent_approval invocation to the plugin. The payload
|
|
258
|
+
* is a signature the operator's wallet produced — no secret material. */
|
|
259
|
+
export async function executeSubmitHlAgentApproval(ctx, args) {
|
|
260
|
+
const tool = ctx.toolMap.hl_submit_agent_approval;
|
|
261
|
+
if (!tool || !ctx.http) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
message: 'hl_submit_agent_approval tool not available on gateway — update the ReefClaw plugin (npx @reefclaw/connect@latest)',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const result = await ctx.http.invoke(tool, {
|
|
269
|
+
action: args.action,
|
|
270
|
+
nonce: args.nonce,
|
|
271
|
+
signature: args.signature,
|
|
272
|
+
operator_token: ctx.operatorToken,
|
|
273
|
+
});
|
|
274
|
+
return result.data;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
278
|
+
logger.error(TAG, `hl_submit_agent_approval failed: ${reason}`);
|
|
279
|
+
return { ok: false, message: reason };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
253
282
|
/** Forward an hl_agent_wallet_status invocation to the plugin. Read-only. */
|
|
254
283
|
export async function executeHlAgentWalletStatus(ctx) {
|
|
255
284
|
const tool = ctx.toolMap.hl_agent_wallet_status;
|
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", "hl_provision_agent_wallet", "hl_agent_wallet_status", "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", "hl_submit_agent_approval", "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
|
@@ -21,6 +21,7 @@ export const ALLOWED_METHODS = [
|
|
|
21
21
|
// Hyperliquid guided onboarding (box-generated agent wallet) — operator.write-gated.
|
|
22
22
|
'hl_provision_agent_wallet',
|
|
23
23
|
'hl_agent_wallet_status',
|
|
24
|
+
'hl_submit_agent_approval',
|
|
24
25
|
// Bracket-orders config (Phase 3.5b) — operator.write-gated.
|
|
25
26
|
'get_bracket_config',
|
|
26
27
|
'set_bracket_requirement',
|
|
@@ -38,6 +39,7 @@ export const OPERATOR_WRITE_METHODS = new Set([
|
|
|
38
39
|
'clear_exchange_credentials',
|
|
39
40
|
'hl_provision_agent_wallet',
|
|
40
41
|
'hl_agent_wallet_status',
|
|
42
|
+
'hl_submit_agent_approval',
|
|
41
43
|
'get_bracket_config',
|
|
42
44
|
'set_bracket_requirement',
|
|
43
45
|
'emergency.kill',
|
|
@@ -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
|
@@ -35,6 +35,7 @@ export const UNGOVERNABLE_TOOLS = new Set([
|
|
|
35
35
|
// operator-only channel; disabling them would strand the HL setup flow.
|
|
36
36
|
'hl_provision_agent_wallet',
|
|
37
37
|
'hl_agent_wallet_status',
|
|
38
|
+
'hl_submit_agent_approval',
|
|
38
39
|
]);
|
|
39
40
|
/** RC_TOOL_GATE: 'off' = kill-switch (gate never blocks), 'shadow' = log
|
|
40
41
|
* would-be blocks but execute anyway, anything else/default = 'enforce'.
|
package/index.js
CHANGED
|
@@ -68,6 +68,7 @@ import { testExchangeCredentialsTool } from './tools/test-exchange-credentials.j
|
|
|
68
68
|
import { clearExchangeCredentialsTool } from './tools/clear-exchange-credentials.js';
|
|
69
69
|
import { hlProvisionAgentWalletTool } from './tools/hl-provision-agent-wallet.js';
|
|
70
70
|
import { hlAgentWalletStatusTool } from './tools/hl-agent-wallet-status.js';
|
|
71
|
+
import { hlSubmitAgentApprovalTool } from './tools/hl-submit-agent-approval.js';
|
|
71
72
|
import { ensureCredentialTransportKey } from './security/sealed-credentials.js';
|
|
72
73
|
// Tool implementations
|
|
73
74
|
import { fetchTickerTool } from './tools/fetch-ticker.js';
|
|
@@ -807,6 +808,7 @@ const TOOL_PARAMS = {
|
|
|
807
808
|
walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… (venue=hyperliquid; never a private key)' },
|
|
808
809
|
agentPrivateKey: { type: 'string', description: 'Hyperliquid AGENT (API) wallet private key — signs only, cannot withdraw (venue=hyperliquid)' },
|
|
809
810
|
testnet: { type: 'boolean', description: 'Use the venue testnet (default: false)' },
|
|
811
|
+
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.' },
|
|
810
812
|
sealed: { type: 'object', description: 'End-to-end encrypted credential envelope from the dashboard (replaces the plaintext fields; only this box can open it)' },
|
|
811
813
|
},
|
|
812
814
|
},
|
|
@@ -839,6 +841,7 @@ const TOOL_PARAMS = {
|
|
|
839
841
|
walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… the agent wallet will trade for (public; never a private key)' },
|
|
840
842
|
testnet: { type: 'boolean', description: 'Provision for the Hyperliquid testnet (default: false)' },
|
|
841
843
|
regenerate: { type: 'boolean', description: 'Replace an existing agent wallet with a fresh key (PAPER mode only; the old approval is orphaned)' },
|
|
844
|
+
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.' },
|
|
842
845
|
},
|
|
843
846
|
},
|
|
844
847
|
hl_agent_wallet_status: {
|
|
@@ -848,6 +851,16 @@ const TOOL_PARAMS = {
|
|
|
848
851
|
probe: { type: 'boolean', description: 'Tool-discovery probe — returns immediately without config or network access' },
|
|
849
852
|
},
|
|
850
853
|
},
|
|
854
|
+
hl_submit_agent_approval: {
|
|
855
|
+
type: 'object',
|
|
856
|
+
properties: {
|
|
857
|
+
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
858
|
+
action: { type: 'object', description: "The wallet-signed approveAgent action, verbatim. Only type='approveAgent' is accepted." },
|
|
859
|
+
nonce: { type: 'number', description: 'Outer nonce — must equal action.nonce (user-signed actions carry one nonce)' },
|
|
860
|
+
signature: { type: 'object', description: 'Wallet signature { r, s, v } over the approveAgent typed data' },
|
|
861
|
+
probe: { type: 'boolean', description: 'Tool-discovery probe — returns immediately without config or network access' },
|
|
862
|
+
},
|
|
863
|
+
},
|
|
851
864
|
clear_exchange_credentials: {
|
|
852
865
|
type: 'object',
|
|
853
866
|
properties: {
|
|
@@ -2724,6 +2737,20 @@ const paperTradingPlugin = {
|
|
|
2724
2737
|
return jsonResult(await hlProvisionAgentWalletTool(params, { runtime, bootVenue: venue }));
|
|
2725
2738
|
},
|
|
2726
2739
|
},
|
|
2740
|
+
{
|
|
2741
|
+
name: 'hl_submit_agent_approval',
|
|
2742
|
+
label: 'Submit Hyperliquid Agent Approval',
|
|
2743
|
+
description: "Operator-only. Submit the operator's wallet-signed approveAgent action to Hyperliquid FROM THIS MACHINE (the dashboard browser is often blocked from reaching the exchange API by ad-blockers/shields). Strictly limited to type='approveAgent' targeting this box's own provisioned agent wallet — it is not a general signed-action relay. Refused without dashboard operator provenance.",
|
|
2744
|
+
parameters: TOOL_PARAMS.hl_submit_agent_approval,
|
|
2745
|
+
execute: async (_id, params) => {
|
|
2746
|
+
if (params?.probe === true)
|
|
2747
|
+
return jsonResult(await hlSubmitAgentApprovalTool(params));
|
|
2748
|
+
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2749
|
+
if (!prov.ok)
|
|
2750
|
+
return jsonResult({ error: prov.error });
|
|
2751
|
+
return jsonResult(await hlSubmitAgentApprovalTool(params));
|
|
2752
|
+
},
|
|
2753
|
+
},
|
|
2727
2754
|
{
|
|
2728
2755
|
name: 'hl_agent_wallet_status',
|
|
2729
2756
|
label: 'Hyperliquid Agent Wallet Status',
|
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.23",
|
|
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": {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"clear_exchange_credentials",
|
|
73
73
|
"hl_provision_agent_wallet",
|
|
74
74
|
"hl_agent_wallet_status",
|
|
75
|
+
"hl_submit_agent_approval",
|
|
75
76
|
"get_bracket_config",
|
|
76
77
|
"set_bracket_requirement"
|
|
77
78
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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",
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { PluginRuntime } from '../onboarding/runtime.js';
|
|
2
2
|
import type { TradingMode } from '../types.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type VenueId } from '../venues/registry.js';
|
|
4
4
|
export interface HlProvisionAgentWalletArgs {
|
|
5
5
|
/** MASTER account address (public, 0x…) the agent wallet will trade for. */
|
|
6
6
|
walletAddress?: string;
|
|
7
7
|
testnet?: boolean;
|
|
8
8
|
/** Mint a fresh key even when one exists (PAPER mode only). */
|
|
9
9
|
regenerate?: boolean;
|
|
10
|
+
/** Acknowledges switching this agent away from a live venue (Binance
|
|
11
|
+
* credentials are PRESERVED either way — see buildVenueExchangeConfig). */
|
|
12
|
+
confirm_venue_switch?: boolean;
|
|
10
13
|
}
|
|
11
14
|
export interface HlProvisionAgentWalletResult {
|
|
12
15
|
ok: boolean;
|
|
@@ -24,6 +27,11 @@ export interface HlProvisionAgentWalletResult {
|
|
|
24
27
|
/** True when the boot venue differs — restart applies the venue. */
|
|
25
28
|
restartRequired?: boolean;
|
|
26
29
|
mode: TradingMode;
|
|
30
|
+
/** Set with ok:false when switching away from a live venue needs explicit
|
|
31
|
+
* acknowledgement. Retry with confirm_venue_switch:true. */
|
|
32
|
+
requiresVenueSwitchConfirm?: boolean;
|
|
33
|
+
currentVenue?: VenueId;
|
|
34
|
+
currentMode?: TradingMode;
|
|
27
35
|
}
|
|
28
36
|
export interface HlProvisionAgentWalletDeps {
|
|
29
37
|
runtime: PluginRuntime;
|
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
// Security: the private key exists only in this process's memory between
|
|
28
28
|
// generation and the config write. It is never logged, never in the result,
|
|
29
29
|
// never egressed (credential-no-egress.test.ts pins this module).
|
|
30
|
-
import { readPluginConfig, updatePluginConfig,
|
|
30
|
+
import { buildVenueExchangeConfig, hasCredentialsForVenue, readPluginConfig, redactCredential, updatePluginConfig, } from '../config/plugin-config-io.js';
|
|
31
31
|
import { deriveAddressFromPrivateKey, generateAgentPrivateKey, isHexAddress, } from '../venues/hyperliquid/hl-agent-wallet.js';
|
|
32
|
+
import { parseVenue } from '../venues/registry.js';
|
|
32
33
|
import { logger } from '../logger.js';
|
|
33
34
|
const TAG = 'hl-provision-agent-wallet';
|
|
34
35
|
export async function hlProvisionAgentWalletTool(args, deps) {
|
|
@@ -49,21 +50,70 @@ export async function hlProvisionAgentWalletTool(args, deps) {
|
|
|
49
50
|
let existingKey = null;
|
|
50
51
|
let existingMaster = null;
|
|
51
52
|
let existingTestnet = false;
|
|
53
|
+
let existingExchange;
|
|
54
|
+
let existingMode;
|
|
52
55
|
try {
|
|
53
56
|
const cfg = readPluginConfig(deps.configPath);
|
|
54
|
-
|
|
57
|
+
existingExchange = cfg.exchange;
|
|
58
|
+
existingMode = cfg.tradingMode;
|
|
59
|
+
// An HL wallet counts as provisioned whenever the key is present — even if
|
|
60
|
+
// the ACTIVE venue is binance (both venues' credentials coexist now), so a
|
|
61
|
+
// trader who set up HL, switched back to Binance, and returns resumes their
|
|
62
|
+
// existing agent wallet instead of silently minting a second one.
|
|
63
|
+
if (typeof cfg.exchange?.agentPrivateKey === 'string') {
|
|
55
64
|
existingKey = cfg.exchange.agentPrivateKey;
|
|
56
65
|
existingMaster = typeof cfg.exchange.walletAddress === 'string' ? cfg.exchange.walletAddress : null;
|
|
57
|
-
existingTestnet = cfg.exchange.testnet === true;
|
|
66
|
+
existingTestnet = cfg.exchange.testnetByVenue?.hyperliquid ?? cfg.exchange.testnet === true;
|
|
58
67
|
}
|
|
59
68
|
}
|
|
60
69
|
catch {
|
|
61
70
|
// Unreadable config — treat as fresh.
|
|
62
71
|
}
|
|
72
|
+
// Warn-and-confirm before switching a live Binance box onto Hyperliquid.
|
|
73
|
+
// Credentials are preserved either way; what needs acknowledging is that
|
|
74
|
+
// after the restart this agent stops managing the other venue's book.
|
|
75
|
+
const fromVenue = parseVenue(existingExchange?.venue).venue;
|
|
76
|
+
const persistedMode = existingMode ?? 'PAPER';
|
|
77
|
+
if (fromVenue !== 'hyperliquid' &&
|
|
78
|
+
hasCredentialsForVenue(existingExchange, fromVenue) &&
|
|
79
|
+
persistedMode !== 'PAPER' &&
|
|
80
|
+
args.confirm_venue_switch !== true) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
message: `This agent is configured for ${fromVenue} in ${persistedMode} mode. Setting up Hyperliquid means ` +
|
|
84
|
+
`that after the restart it will trade Hyperliquid and STOP managing anything open on ${fromVenue} — ` +
|
|
85
|
+
`positions there stay open on the exchange with their protective stops, but this agent will not ` +
|
|
86
|
+
`monitor or close them. Your ${fromVenue} credentials are KEPT, so you can switch back at any time. ` +
|
|
87
|
+
`Confirm to proceed.`,
|
|
88
|
+
venue: 'hyperliquid',
|
|
89
|
+
mode,
|
|
90
|
+
requiresVenueSwitchConfirm: true,
|
|
91
|
+
currentVenue: fromVenue,
|
|
92
|
+
currentMode: persistedMode,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
63
95
|
if (existingKey && args.regenerate !== true) {
|
|
64
96
|
const derived = await deriveAddressFromPrivateKey(existingKey);
|
|
65
97
|
if (derived.ok) {
|
|
66
98
|
const masterMatches = existingMaster != null && existingMaster.toLowerCase() === walletAddress.toLowerCase();
|
|
99
|
+
// ★ Honour a CHANGED network on the resume path. The same keypair is
|
|
100
|
+
// valid on both Hyperliquid networks, so switching mainnet<->testnet
|
|
101
|
+
// must not require regenerating the wallet — but the stored flag has to
|
|
102
|
+
// follow, or the box boots against the network the operator did NOT
|
|
103
|
+
// pick. Before this, unticking "Use Hyperliquid testnet" after
|
|
104
|
+
// provisioning silently kept testnet:true, and the approval was signed
|
|
105
|
+
// for Testnet while the operator believed they were on mainnet
|
|
106
|
+
// (observed live 2026-08-03).
|
|
107
|
+
if (masterMatches && existingTestnet !== testnet) {
|
|
108
|
+
try {
|
|
109
|
+
updatePluginConfig({ exchange: buildVenueExchangeConfig(existingExchange, 'hyperliquid', {}, testnet) }, deps.configPath);
|
|
110
|
+
existingTestnet = testnet;
|
|
111
|
+
logger.info(TAG, `network switched to ${testnet ? 'TESTNET' : 'MAINNET'} (same agent wallet)`);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
logger.warn(TAG, `could not persist network change: ${err instanceof Error ? err.message : String(err)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
67
117
|
return {
|
|
68
118
|
ok: true,
|
|
69
119
|
message: masterMatches
|
|
@@ -107,12 +157,9 @@ export async function hlProvisionAgentWalletTool(args, deps) {
|
|
|
107
157
|
mode,
|
|
108
158
|
};
|
|
109
159
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
agentPrivateKey,
|
|
114
|
-
testnet,
|
|
115
|
-
};
|
|
160
|
+
// Merge, never replace: a Binance key/secret already on this box survives
|
|
161
|
+
// (the trader can switch back without re-entering anything).
|
|
162
|
+
const exchange = buildVenueExchangeConfig(existingExchange, 'hyperliquid', { walletAddress, agentPrivateKey }, testnet);
|
|
116
163
|
try {
|
|
117
164
|
updatePluginConfig({ exchange }, deps.configPath);
|
|
118
165
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface HlSubmitAgentApprovalArgs {
|
|
2
|
+
/** The approveAgent action, byte-identical to what the wallet signed. */
|
|
3
|
+
action?: Record<string, unknown>;
|
|
4
|
+
/** Outer nonce — for user-signed actions it must equal action.nonce. */
|
|
5
|
+
nonce?: number;
|
|
6
|
+
signature?: {
|
|
7
|
+
r?: unknown;
|
|
8
|
+
s?: unknown;
|
|
9
|
+
v?: unknown;
|
|
10
|
+
};
|
|
11
|
+
/** Tool-discovery probe — returns immediately, no config or network. */
|
|
12
|
+
probe?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface HlSubmitAgentApprovalResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
/** Hyperliquid's own status string when it answered ('ok' | 'err'). */
|
|
18
|
+
hlStatus?: string;
|
|
19
|
+
/** Which network the SIGNED action targeted. */
|
|
20
|
+
chain?: 'Mainnet' | 'Testnet';
|
|
21
|
+
agentAddress?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface HlSubmitAgentApprovalDeps {
|
|
24
|
+
configPath?: string;
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
export declare function hlSubmitAgentApprovalTool(args: HlSubmitAgentApprovalArgs, deps?: HlSubmitAgentApprovalDeps): Promise<HlSubmitAgentApprovalResult>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// hl_submit_agent_approval — operator-only plugin tool.
|
|
2
|
+
//
|
|
3
|
+
// Submits the operator's WALLET-SIGNED `approveAgent` action to Hyperliquid
|
|
4
|
+
// from THIS BOX, instead of from the dashboard's browser.
|
|
5
|
+
//
|
|
6
|
+
// ★ Why the box and not the browser (real failure, 2026-08-03): the guided
|
|
7
|
+
// flow POSTed to api.hyperliquid*.xyz directly from the dashboard page and an
|
|
8
|
+
// operator hit "Failed to fetch" twice. The endpoint was fine (CORS is `*`,
|
|
9
|
+
// POSTs answer normally) and the box reached it in 0.49s — the request was
|
|
10
|
+
// blocked INSIDE the browser (ad-blocker / shield / DNS policy; crypto API
|
|
11
|
+
// domains are routinely blocked). Making a required onboarding step depend on
|
|
12
|
+
// the operator's browser extensions is the wrong architecture: the box is
|
|
13
|
+
// already the Hyperliquid client (every read and every future order goes
|
|
14
|
+
// through it), so it submits. The browser's only irreplaceable job is holding
|
|
15
|
+
// the wallet and producing the signature. ReefClaw's servers stay out of the
|
|
16
|
+
// path — the signed action travels over the existing relay to the user's own
|
|
17
|
+
// machine.
|
|
18
|
+
//
|
|
19
|
+
// ★ SECURITY — this is deliberately NOT a generic signed-action relay.
|
|
20
|
+
// A tool that forwarded any {action, signature} pair to /exchange would be a
|
|
21
|
+
// capability escalation: HL's withdraw / usdSend / usdClassTransfer are all
|
|
22
|
+
// user-signed actions in the same envelope shape. If a signature for one of
|
|
23
|
+
// those ever existed, a generic relay would happily submit it. So:
|
|
24
|
+
// 1. `action.type` MUST be exactly 'approveAgent' — nothing else is accepted.
|
|
25
|
+
// 2. `action.agentAddress` MUST be the address THIS box's provisioned agent
|
|
26
|
+
// key controls, so the tool can only ever authorize our own wallet.
|
|
27
|
+
// 3. The endpoint is chosen from the SIGNED action's `hyperliquidChain`, so
|
|
28
|
+
// it cannot be pointed at a different network than the one signed for.
|
|
29
|
+
// The signature itself is produced and validated by Hyperliquid; we never
|
|
30
|
+
// touch key material here (the agent key is only used to DERIVE its public
|
|
31
|
+
// address for check 2).
|
|
32
|
+
import { readPluginConfig } from '../config/plugin-config-io.js';
|
|
33
|
+
import { deriveAddressFromPrivateKey, isHexAddress, sameAddress } from '../venues/hyperliquid/hl-agent-wallet.js';
|
|
34
|
+
import { hlApiBase } from '../venues/hyperliquid/hl-preflight.js';
|
|
35
|
+
import { logger } from '../logger.js';
|
|
36
|
+
const TAG = 'hl-submit-agent-approval';
|
|
37
|
+
function fail(message, extra = {}) {
|
|
38
|
+
return { ok: false, message, ...extra };
|
|
39
|
+
}
|
|
40
|
+
export async function hlSubmitAgentApprovalTool(args, deps = {}) {
|
|
41
|
+
if (args?.probe === true) {
|
|
42
|
+
return { ok: false, message: 'probe' };
|
|
43
|
+
}
|
|
44
|
+
const action = args?.action;
|
|
45
|
+
if (!action || typeof action !== 'object') {
|
|
46
|
+
return fail('action is required (the approveAgent object the wallet signed)');
|
|
47
|
+
}
|
|
48
|
+
// 1. Only ever approveAgent — never a generic relay (see header).
|
|
49
|
+
if (action.type !== 'approveAgent') {
|
|
50
|
+
logger.warn(TAG, `refused non-approveAgent action type: ${String(action.type)}`);
|
|
51
|
+
return fail(`Refused: this tool only submits 'approveAgent' actions, not '${String(action.type)}'.`);
|
|
52
|
+
}
|
|
53
|
+
const chain = action.hyperliquidChain;
|
|
54
|
+
if (chain !== 'Mainnet' && chain !== 'Testnet') {
|
|
55
|
+
return fail("action.hyperliquidChain must be 'Mainnet' or 'Testnet'");
|
|
56
|
+
}
|
|
57
|
+
const agentAddress = action.agentAddress;
|
|
58
|
+
if (!isHexAddress(agentAddress)) {
|
|
59
|
+
return fail('action.agentAddress must be a 0x… address', { chain });
|
|
60
|
+
}
|
|
61
|
+
const sig = args?.signature;
|
|
62
|
+
const r = sig?.r;
|
|
63
|
+
const s = sig?.s;
|
|
64
|
+
const v = sig?.v;
|
|
65
|
+
if (typeof r !== 'string' || typeof s !== 'string' || typeof v !== 'number') {
|
|
66
|
+
return fail('signature must be { r: string, s: string, v: number }', { chain });
|
|
67
|
+
}
|
|
68
|
+
const nonce = args?.nonce;
|
|
69
|
+
// User-signed actions carry ONE nonce — the outer value must match the one
|
|
70
|
+
// inside the signed payload, or HL verifies a different message than we send.
|
|
71
|
+
if (typeof nonce !== 'number' || nonce !== action.nonce) {
|
|
72
|
+
return fail('nonce must be a number equal to action.nonce', { chain });
|
|
73
|
+
}
|
|
74
|
+
// 2. The agent being approved must be OUR provisioned wallet.
|
|
75
|
+
let storedKey;
|
|
76
|
+
try {
|
|
77
|
+
const cfg = readPluginConfig(deps.configPath);
|
|
78
|
+
if (typeof cfg.exchange?.agentPrivateKey === 'string')
|
|
79
|
+
storedKey = cfg.exchange.agentPrivateKey;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// fall through to the not-provisioned error
|
|
83
|
+
}
|
|
84
|
+
if (!storedKey) {
|
|
85
|
+
return fail('No Hyperliquid agent wallet is provisioned on this machine — run the guided setup first.', { chain });
|
|
86
|
+
}
|
|
87
|
+
const derived = await deriveAddressFromPrivateKey(storedKey);
|
|
88
|
+
if (!derived.ok) {
|
|
89
|
+
return fail('Could not derive this machine’s agent wallet address — cannot verify what is being approved.', { chain });
|
|
90
|
+
}
|
|
91
|
+
if (!sameAddress(derived.address, agentAddress)) {
|
|
92
|
+
logger.warn(TAG, 'refused: action.agentAddress is not this box’s provisioned agent');
|
|
93
|
+
return fail('Refused: the approval targets a different agent wallet than the one provisioned on this machine.', { chain, agentAddress: derived.address });
|
|
94
|
+
}
|
|
95
|
+
// 3. Submit — endpoint from the SIGNED chain, never from local config.
|
|
96
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
97
|
+
const url = `${hlApiBase(chain === 'Testnet')}/exchange`;
|
|
98
|
+
logger.info(TAG, `submitting approveAgent for ${agentAddress} to ${chain}`);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetchImpl(url, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ action, nonce, signature: { r, s, v } }),
|
|
104
|
+
signal: AbortSignal.timeout(20_000),
|
|
105
|
+
});
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
let body = null;
|
|
108
|
+
try {
|
|
109
|
+
body = JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// non-JSON — surfaced verbatim below
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok || body?.status !== 'ok') {
|
|
115
|
+
const detail = typeof body?.response === 'string' ? body.response : text.slice(0, 300) || `HTTP ${res.status}`;
|
|
116
|
+
logger.warn(TAG, `Hyperliquid rejected the approval: ${detail}`);
|
|
117
|
+
return fail(`Hyperliquid rejected the approval: ${detail}`, {
|
|
118
|
+
hlStatus: body?.status,
|
|
119
|
+
chain,
|
|
120
|
+
agentAddress: derived.address,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
logger.info(TAG, `approveAgent accepted by Hyperliquid (${chain})`);
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
message: `Approval submitted and accepted by Hyperliquid (${chain}).`,
|
|
127
|
+
hlStatus: 'ok',
|
|
128
|
+
chain,
|
|
129
|
+
agentAddress: derived.address,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
134
|
+
logger.error(TAG, `submit failed: ${msg}`);
|
|
135
|
+
return fail(`Could not reach Hyperliquid from this machine: ${msg}`, {
|
|
136
|
+
chain,
|
|
137
|
+
agentAddress: derived.address,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -10,6 +10,9 @@ export interface SetExchangeCredentialsArgs {
|
|
|
10
10
|
walletAddress?: string;
|
|
11
11
|
agentPrivateKey?: string;
|
|
12
12
|
testnet?: boolean;
|
|
13
|
+
/** Acknowledges a consequential venue switch (see venueSwitchNeedsConfirm).
|
|
14
|
+
* The dashboard sets this after showing the operator what changes. */
|
|
15
|
+
confirm_venue_switch?: boolean;
|
|
13
16
|
/** End-to-end encrypted envelope from the dashboard (sealed-credentials.ts).
|
|
14
17
|
* When present it is the SOLE source of the credential fields — any
|
|
15
18
|
* plaintext siblings are ignored, so a mixed payload can't smuggle values
|
|
@@ -37,6 +40,13 @@ export interface SetExchangeCredentialsResult {
|
|
|
37
40
|
/** True when the stored venue differs from the booted venue — the operator
|
|
38
41
|
* must restart the agent before the change takes effect. */
|
|
39
42
|
restartRequired?: boolean;
|
|
43
|
+
/** Set with ok:false when a venue switch needs explicit acknowledgement.
|
|
44
|
+
* Retry the same call with confirm_venue_switch:true to proceed. */
|
|
45
|
+
requiresVenueSwitchConfirm?: boolean;
|
|
46
|
+
/** Venue currently configured on the box (only with the confirm request). */
|
|
47
|
+
currentVenue?: VenueId;
|
|
48
|
+
/** Trading mode currently persisted (only with the confirm request). */
|
|
49
|
+
currentMode?: TradingMode;
|
|
40
50
|
}
|
|
41
51
|
export interface SetExchangeCredentialsDeps {
|
|
42
52
|
runtime: PluginRuntime;
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// to store it when it equals the master walletAddress — that means the user
|
|
29
29
|
// pasted their MASTER private key, the one mistake that must never reach
|
|
30
30
|
// disk. Fail-open when derivation infra is unavailable (never block on it).
|
|
31
|
-
import {
|
|
31
|
+
import { buildVenueExchangeConfig, hasCredentialsForVenue, readPluginConfig, redactCredential, updatePluginConfig, } from '../config/plugin-config-io.js';
|
|
32
32
|
import { deriveAddressFromPrivateKey, isHexAddress, isHexPrivateKey, normalizeHexPrivateKey, sameAddress, } from '../venues/hyperliquid/hl-agent-wallet.js';
|
|
33
33
|
import { parseVenue } from '../venues/registry.js';
|
|
34
34
|
import { unsealCredentials, SealedEnvelopeError } from '../security/sealed-credentials.js';
|
|
@@ -84,6 +84,28 @@ function validateHyperliquid(args) {
|
|
|
84
84
|
}
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
|
+
/** True when the caller supplied no credential fields for `venue` — the
|
|
88
|
+
* "just switch me back, my keys are already saved" path. */
|
|
89
|
+
function isActivationOnly(args, venue) {
|
|
90
|
+
const fields = venue === 'hyperliquid'
|
|
91
|
+
? [args.walletAddress, args.agentPrivateKey]
|
|
92
|
+
: [args.apiKey, args.secret];
|
|
93
|
+
return fields.every((f) => f === undefined || (typeof f === 'string' && f.trim().length === 0));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Venue switches are a warn-and-confirm, never a block (operator decision
|
|
97
|
+
* 2026-08-02: traders run both venues and must be able to move between them).
|
|
98
|
+
* We ask for confirmation only when the switch has real consequences: the box
|
|
99
|
+
* holds credentials for the venue being left AND is configured for a live
|
|
100
|
+
* mode, i.e. there may be a real book on the other exchange that this agent
|
|
101
|
+
* will stop managing after the restart. PAPER→anything switches silently.
|
|
102
|
+
*/
|
|
103
|
+
function venueSwitchNeedsConfirm(cfg, target) {
|
|
104
|
+
const from = parseVenue(cfg.exchange?.venue).venue;
|
|
105
|
+
const mode = cfg.tradingMode ?? 'PAPER';
|
|
106
|
+
const needed = from !== target && hasCredentialsForVenue(cfg.exchange, from) && mode !== 'PAPER';
|
|
107
|
+
return { needed, from, mode };
|
|
108
|
+
}
|
|
87
109
|
export async function setExchangeCredentialsTool(rawArgs, deps) {
|
|
88
110
|
const resolved = resolveSealedArgs(rawArgs, deps.transportKeyDir);
|
|
89
111
|
if ('error' in resolved) {
|
|
@@ -92,13 +114,61 @@ export async function setExchangeCredentialsTool(rawArgs, deps) {
|
|
|
92
114
|
const args = resolved.args;
|
|
93
115
|
const venue = parseVenue(args.venue).venue;
|
|
94
116
|
const bootVenue = deps.bootVenue ?? 'binance';
|
|
95
|
-
|
|
117
|
+
// Read once: needed for the switch gate AND to preserve the other venue's
|
|
118
|
+
// settings (the block is merged, never replaced — see buildVenueExchangeConfig).
|
|
119
|
+
let existing;
|
|
120
|
+
let existingMode;
|
|
121
|
+
try {
|
|
122
|
+
const cfg = readPluginConfig(deps.configPath);
|
|
123
|
+
existing = cfg.exchange;
|
|
124
|
+
existingMode = cfg.tradingMode;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Unreadable/absent config — treat as a fresh box.
|
|
128
|
+
}
|
|
129
|
+
// Activation-only: no credentials supplied, but this venue already has some
|
|
130
|
+
// stored → the operator is switching back to a venue they configured before.
|
|
131
|
+
const activationOnly = isActivationOnly(args, venue);
|
|
132
|
+
if (activationOnly && !hasCredentialsForVenue(existing, venue)) {
|
|
133
|
+
return fail(venue === 'hyperliquid'
|
|
134
|
+
? 'No Hyperliquid credentials are stored yet — provide walletAddress + agentPrivateKey (or use the guided setup).'
|
|
135
|
+
: 'No Binance credentials are stored yet — provide apiKey + secret.', '(not-set)', deps, venue);
|
|
136
|
+
}
|
|
137
|
+
const validationError = activationOnly
|
|
138
|
+
? null
|
|
139
|
+
: venue === 'hyperliquid'
|
|
140
|
+
? validateHyperliquid(args)
|
|
141
|
+
: validateBinance(args);
|
|
96
142
|
if (validationError) {
|
|
97
143
|
return fail(validationError, '(not-set)', deps, venue);
|
|
98
144
|
}
|
|
145
|
+
// Warn-and-confirm on a consequential venue switch (never a hard block).
|
|
146
|
+
const gate = venueSwitchNeedsConfirm({ exchange: existing, tradingMode: existingMode }, venue);
|
|
147
|
+
if (gate.needed && args.confirm_venue_switch !== true) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
message: `This agent is configured for ${gate.from} in ${gate.mode} mode. Switching it to ${venue} means ` +
|
|
151
|
+
`that after the restart it will trade ${venue} and STOP managing anything open on ${gate.from} — ` +
|
|
152
|
+
`positions there stay open on the exchange with their protective stops, but this agent will not ` +
|
|
153
|
+
`monitor or close them. Your ${gate.from} credentials are KEPT, so you can switch back at any time. ` +
|
|
154
|
+
`Confirm to proceed.`,
|
|
155
|
+
fingerprint: '(not-set)',
|
|
156
|
+
reconnected: false,
|
|
157
|
+
mode: deps.runtime.mode,
|
|
158
|
+
readiness: deps.runtime.adapter.readiness,
|
|
159
|
+
venue,
|
|
160
|
+
requiresVenueSwitchConfirm: true,
|
|
161
|
+
currentVenue: gate.from,
|
|
162
|
+
currentMode: gate.mode,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
99
165
|
let exchange;
|
|
100
166
|
let fingerprint;
|
|
101
|
-
if (
|
|
167
|
+
if (activationOnly) {
|
|
168
|
+
exchange = buildVenueExchangeConfig(existing, venue, {}, args.testnet);
|
|
169
|
+
fingerprint = redactCredential(venue === 'hyperliquid' ? (exchange.walletAddress ?? '') : (exchange.apiKey ?? ''));
|
|
170
|
+
}
|
|
171
|
+
else if (venue === 'hyperliquid') {
|
|
102
172
|
const walletAddress = args.walletAddress.trim();
|
|
103
173
|
const agentPrivateKey = normalizeHexPrivateKey(args.agentPrivateKey);
|
|
104
174
|
// The one unrecoverable mistake: the pasted "agent key" controls the MASTER
|
|
@@ -113,26 +183,25 @@ export async function setExchangeCredentialsTool(rawArgs, deps) {
|
|
|
113
183
|
if (!derived.ok && derived.reason === 'invalid_key') {
|
|
114
184
|
return fail('agentPrivateKey is not a valid secp256k1 private key — re-copy it from the Hyperliquid API page', redactCredential(walletAddress), deps, venue);
|
|
115
185
|
}
|
|
116
|
-
exchange = {
|
|
117
|
-
venue: 'hyperliquid',
|
|
118
|
-
walletAddress,
|
|
119
|
-
agentPrivateKey,
|
|
120
|
-
testnet: args.testnet ?? false,
|
|
121
|
-
};
|
|
186
|
+
exchange = buildVenueExchangeConfig(existing, 'hyperliquid', { walletAddress, agentPrivateKey }, args.testnet);
|
|
122
187
|
// The master ADDRESS is public (it identifies the account on-chain) — safe
|
|
123
188
|
// as a fingerprint; the agent key never appears anywhere.
|
|
124
189
|
fingerprint = redactCredential(walletAddress);
|
|
125
190
|
}
|
|
126
|
-
else {
|
|
191
|
+
else if (args.venue === undefined && existing === undefined) {
|
|
192
|
+
// Legacy caller on a fresh box: keep the historical on-disk shape exactly
|
|
193
|
+
// (no `venue`, no `testnetByVenue`) so pre-venue installs are byte-identical.
|
|
127
194
|
exchange = {
|
|
128
|
-
// Legacy callers omit `venue`; keep their on-disk shape byte-identical.
|
|
129
|
-
...(args.venue !== undefined ? { venue: 'binance' } : {}),
|
|
130
195
|
apiKey: args.apiKey.trim(),
|
|
131
196
|
secret: args.secret.trim(),
|
|
132
197
|
testnet: args.testnet ?? false,
|
|
133
198
|
};
|
|
134
199
|
fingerprint = redactCredential(exchange.apiKey);
|
|
135
200
|
}
|
|
201
|
+
else {
|
|
202
|
+
exchange = buildVenueExchangeConfig(existing, 'binance', { apiKey: args.apiKey.trim(), secret: args.secret.trim() }, args.testnet);
|
|
203
|
+
fingerprint = redactCredential(exchange.apiKey);
|
|
204
|
+
}
|
|
136
205
|
try {
|
|
137
206
|
updatePluginConfig({ exchange }, deps.configPath);
|
|
138
207
|
}
|
package/types.d.ts
CHANGED
|
@@ -130,4 +130,10 @@ export interface ExchangeConfig {
|
|
|
130
130
|
testnet?: boolean;
|
|
131
131
|
walletAddress?: string;
|
|
132
132
|
agentPrivateKey?: string;
|
|
133
|
+
/** Per-venue testnet memo. `testnet` above is the ACTIVE venue's flag (what
|
|
134
|
+
* boot reads); this remembers the OTHER venue's setting so switching back
|
|
135
|
+
* restores it instead of inheriting the flag from whichever venue was
|
|
136
|
+
* configured last. Traders run both venues and switch between them — a
|
|
137
|
+
* switch must never lose a configured setting. */
|
|
138
|
+
testnetByVenue?: Partial<Record<'binance' | 'hyperliquid', boolean>>;
|
|
133
139
|
}
|