@reefclaw/openclaw-plugin 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge/bridge.js CHANGED
@@ -712,7 +712,10 @@ export class Bridge {
712
712
  }
713
713
  const kid = sealed.kid;
714
714
  return {
715
- req: { sealed: sealed },
715
+ req: {
716
+ sealed: sealed,
717
+ ...(params?.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
718
+ },
716
719
  fp: `sealed:${typeof kid === 'string' ? kid.slice(0, 16) : 'unknown'}`,
717
720
  };
718
721
  }
@@ -720,35 +723,55 @@ export class Bridge {
720
723
  if (params?.venue === 'hyperliquid') {
721
724
  const walletAddress = typeof params?.walletAddress === 'string' ? params.walletAddress.trim() : '';
722
725
  const agentPrivateKey = typeof params?.agentPrivateKey === 'string' ? params.agentPrivateKey.trim() : '';
723
- if (!/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
724
- this.connector.sendResponse(id, false, undefined, {
725
- code: 400,
726
- message: 'walletAddress must be your MASTER account address: 0x followed by 40 hex characters',
727
- });
728
- return null;
729
- }
730
- if (!/^(0x)?[0-9a-fA-F]{64}$/.test(agentPrivateKey)) {
731
- this.connector.sendResponse(id, false, undefined, {
732
- code: 400,
733
- message: 'agentPrivateKey must be an agent (API) wallet private key: 64 hex characters (0x prefix optional)',
734
- });
735
- return null;
726
+ // Activation-only (both fields absent) = "switch back to Hyperliquid,
727
+ // my credentials are already stored" — the plugin resolves it against
728
+ // what is on disk and errors if nothing is stored.
729
+ const activationOnly = walletAddress === '' && agentPrivateKey === '';
730
+ if (!activationOnly) {
731
+ if (!/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
732
+ this.connector.sendResponse(id, false, undefined, {
733
+ code: 400,
734
+ message: 'walletAddress must be your MASTER account address: 0x followed by 40 hex characters',
735
+ });
736
+ return null;
737
+ }
738
+ if (!/^(0x)?[0-9a-fA-F]{64}$/.test(agentPrivateKey)) {
739
+ this.connector.sendResponse(id, false, undefined, {
740
+ code: 400,
741
+ message: 'agentPrivateKey must be an agent (API) wallet private key: 64 hex characters (0x prefix optional)',
742
+ });
743
+ return null;
744
+ }
736
745
  }
737
746
  return {
738
- req: { venue: 'hyperliquid', walletAddress, agentPrivateKey, testnet },
739
- fp: fingerprint(walletAddress),
747
+ req: {
748
+ venue: 'hyperliquid',
749
+ ...(activationOnly ? {} : { walletAddress, agentPrivateKey }),
750
+ testnet,
751
+ ...(params?.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
752
+ },
753
+ fp: activationOnly ? '(stored)' : fingerprint(walletAddress),
740
754
  };
741
755
  }
742
756
  const apiKey = typeof params?.apiKey === 'string' ? params.apiKey : '';
743
757
  const secret = typeof params?.secret === 'string' ? params.secret : '';
744
- if (apiKey.length < 8 || secret.length < 8) {
758
+ const activationOnly = apiKey === '' && secret === '' && params?.venue === 'binance';
759
+ if (!activationOnly && (apiKey.length < 8 || secret.length < 8)) {
745
760
  this.connector.sendResponse(id, false, undefined, {
746
761
  code: 400,
747
762
  message: 'apiKey and secret must be at least 8 characters each',
748
763
  });
749
764
  return null;
750
765
  }
751
- return { req: { venue: 'binance', apiKey, secret, testnet }, fp: fingerprint(apiKey) };
766
+ return {
767
+ req: {
768
+ venue: 'binance',
769
+ ...(activationOnly ? {} : { apiKey, secret }),
770
+ testnet,
771
+ ...(params?.confirm_venue_switch === true ? { confirm_venue_switch: true } : {}),
772
+ },
773
+ fp: activationOnly ? '(stored)' : fingerprint(apiKey),
774
+ };
752
775
  }
753
776
  /** Handle a set_exchange_credentials request. The secret value passes
754
777
  * through once and is never cached or logged; only a redacted fingerprint
@@ -858,6 +881,7 @@ export class Bridge {
858
881
  walletAddress,
859
882
  testnet: params?.testnet === true,
860
883
  regenerate: params?.regenerate === true,
884
+ confirm_venue_switch: params?.confirm_venue_switch === true,
861
885
  });
862
886
  audit('hl_provision_agent_wallet.complete', {
863
887
  id,
@@ -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: string;
13
- secret: string;
12
+ apiKey?: string;
13
+ secret?: string;
14
14
  testnet?: boolean;
15
+ confirm_venue_switch?: boolean;
15
16
  } | {
16
17
  venue: 'hyperliquid';
17
- walletAddress: string;
18
- agentPrivateKey: string;
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,9 @@ export interface HlProvisionOutcome {
32
35
  masterMatches?: boolean;
33
36
  restartRequired?: boolean;
34
37
  mode: string;
38
+ requiresVenueSwitchConfirm?: boolean;
39
+ currentVenue?: string;
40
+ currentMode?: string;
35
41
  }
36
42
  /** Result of hl_agent_wallet_status — approval polling for the guided flow. */
37
43
  export interface HlAgentWalletStatusOutcome {
@@ -156,6 +162,11 @@ export interface OpenClawProvider {
156
162
  /** True when the stored venue differs from the booted venue — the
157
163
  * operator must restart the agent before the change takes effect. */
158
164
  restartRequired?: boolean;
165
+ /** ok:false + this ⇒ the venue switch needs acknowledgement; retry with
166
+ * confirm_venue_switch:true. */
167
+ requiresVenueSwitchConfirm?: boolean;
168
+ currentVenue?: string;
169
+ currentMode?: string;
159
170
  }>;
160
171
  /** Operator-only: verify exchange credentials with transient read-only
161
172
  * calls, per venue. Nothing is persisted. */
@@ -180,6 +191,7 @@ export interface OpenClawProvider {
180
191
  walletAddress: string;
181
192
  testnet?: boolean;
182
193
  regenerate?: boolean;
194
+ confirm_venue_switch?: boolean;
183
195
  }): Promise<HlProvisionOutcome>;
184
196
  /** Operator-only: approval/balance status of the box's provisioned HL agent
185
197
  * wallet (polled by the dashboard's guided flow after the wallet signature). */
@@ -227,6 +227,7 @@ 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>;
231
232
  /** Operator-only. Approval/balance status of the provisioned HL wallet. */
232
233
  getHlAgentWalletStatus(): Promise<HlAgentWalletStatusOutcome>;
@@ -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,6 +114,7 @@ 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>;
114
119
  /** Forward an hl_agent_wallet_status invocation to the plugin. Read-only. */
115
120
  export declare function executeHlAgentWalletStatus(ctx: OnboardingContext): Promise<HlAgentWalletStatusOutcome>;
@@ -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;
@@ -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/index.js CHANGED
@@ -807,6 +807,7 @@ const TOOL_PARAMS = {
807
807
  walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… (venue=hyperliquid; never a private key)' },
808
808
  agentPrivateKey: { type: 'string', description: 'Hyperliquid AGENT (API) wallet private key — signs only, cannot withdraw (venue=hyperliquid)' },
809
809
  testnet: { type: 'boolean', description: 'Use the venue testnet (default: false)' },
810
+ confirm_venue_switch: { type: 'boolean', description: 'Acknowledge switching this agent to another venue while it is configured live on the current one. Credentials for both venues are kept either way.' },
810
811
  sealed: { type: 'object', description: 'End-to-end encrypted credential envelope from the dashboard (replaces the plaintext fields; only this box can open it)' },
811
812
  },
812
813
  },
@@ -839,6 +840,7 @@ const TOOL_PARAMS = {
839
840
  walletAddress: { type: 'string', description: 'Hyperliquid MASTER account address 0x… the agent wallet will trade for (public; never a private key)' },
840
841
  testnet: { type: 'boolean', description: 'Provision for the Hyperliquid testnet (default: false)' },
841
842
  regenerate: { type: 'boolean', description: 'Replace an existing agent wallet with a fresh key (PAPER mode only; the old approval is orphaned)' },
843
+ confirm_venue_switch: { type: 'boolean', description: 'Acknowledge switching this agent to Hyperliquid while it is configured live on another venue. Credentials for both venues are kept either way.' },
842
844
  },
843
845
  },
844
846
  hl_agent_wallet_status: {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.21",
4
+ "version": "0.1.22",
5
5
  "description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
6
6
  "author": "ReefClaw",
7
7
  "activation": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode \u00e2\u20ac\u201d it cannot trade real funds until you supply exchange credentials and walk the PAPER\u00e2\u2020\u2019MICRO_LIVE\u00e2\u2020\u2019LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw \u00e2\u20ac\u201d a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,12 +1,15 @@
1
1
  import type { PluginRuntime } from '../onboarding/runtime.js';
2
2
  import type { TradingMode } from '../types.js';
3
- import type { VenueId } from '../venues/registry.js';
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, redactCredential } from '../config/plugin-config-io.js';
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,17 +50,48 @@ 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
- if (cfg.exchange?.venue === 'hyperliquid' && typeof cfg.exchange.agentPrivateKey === 'string') {
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) {
@@ -107,12 +139,9 @@ export async function hlProvisionAgentWalletTool(args, deps) {
107
139
  mode,
108
140
  };
109
141
  }
110
- const exchange = {
111
- venue: 'hyperliquid',
112
- walletAddress,
113
- agentPrivateKey,
114
- testnet,
115
- };
142
+ // Merge, never replace: a Binance key/secret already on this box survives
143
+ // (the trader can switch back without re-entering anything).
144
+ const exchange = buildVenueExchangeConfig(existingExchange, 'hyperliquid', { walletAddress, agentPrivateKey }, testnet);
116
145
  try {
117
146
  updatePluginConfig({ exchange }, deps.configPath);
118
147
  }
@@ -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 { updatePluginConfig, redactCredential } from '../config/plugin-config-io.js';
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
- const validationError = venue === 'hyperliquid' ? validateHyperliquid(args) : validateBinance(args);
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 (venue === 'hyperliquid') {
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
  }