@reefclaw/openclaw-plugin 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bridge/bridge.d.ts +10 -0
  2. package/bridge/bridge.js +142 -22
  3. package/bridge/gateway/gateway-ws-client.d.ts +7 -1
  4. package/bridge/gateway/gateway-ws-client.js +31 -8
  5. package/bridge/gateway/tool-discovery.d.ts +1 -1
  6. package/bridge/gateway/tool-discovery.js +11 -0
  7. package/bridge/provider.d.ts +73 -6
  8. package/bridge/providers/gateway.d.ts +12 -3
  9. package/bridge/providers/gateway.js +18 -5
  10. package/bridge/providers/onboarding-commands.d.ts +33 -7
  11. package/bridge/providers/onboarding-commands.js +109 -13
  12. package/bridge/types.d.ts +1 -1
  13. package/bridge/types.js +5 -0
  14. package/ccxt/binance-private.js +6 -0
  15. package/config/tool-gate.js +4 -0
  16. package/index.js +77 -12
  17. package/ingest/position-auto-capture.js +19 -0
  18. package/ingest/readiness-reporter.js +25 -1
  19. package/openclaw.plugin.json +3 -1
  20. package/package.json +38 -38
  21. package/security/sealed-credentials.d.ts +38 -0
  22. package/security/sealed-credentials.js +180 -0
  23. package/tools/clear-exchange-credentials.js +16 -3
  24. package/tools/hl-agent-wallet-status.d.ts +29 -0
  25. package/tools/hl-agent-wallet-status.js +100 -0
  26. package/tools/hl-provision-agent-wallet.d.ts +35 -0
  27. package/tools/hl-provision-agent-wallet.js +144 -0
  28. package/tools/set-exchange-credentials.d.ts +32 -3
  29. package/tools/set-exchange-credentials.js +144 -32
  30. package/tools/set-trading-mode.d.ts +7 -0
  31. package/tools/set-trading-mode.js +15 -0
  32. package/tools/test-exchange-credentials.d.ts +30 -3
  33. package/tools/test-exchange-credentials.js +173 -35
  34. package/types.d.ts +10 -11
  35. package/venues/hyperliquid/hl-agent-wallet.d.ts +29 -0
  36. package/venues/hyperliquid/hl-agent-wallet.js +118 -0
  37. package/venues/hyperliquid/hl-preflight.d.ts +28 -0
  38. package/venues/hyperliquid/hl-preflight.js +116 -0
@@ -0,0 +1,180 @@
1
+ // Sealed credential transport — the box side of the dashboard→box envelope.
2
+ //
3
+ // Why: exchange credentials entered in the dashboard transit browser → relay
4
+ // (PartyKit/Cloudflare) → bridge → plugin. They were already never STORED and
5
+ // never LOGGED off-box, but they were readable in memory by every hop. This
6
+ // module removes that: the browser encrypts the credential payload to a
7
+ // public key only this box holds, so the relay and the webapp carry
8
+ // ciphertext they cannot open. The private half never leaves
9
+ // ~/.reefclaw/credential-transport-key.json (0600).
10
+ //
11
+ // Scheme (ECIES, deliberately boring):
12
+ // recipient: static ECDH P-256 keypair (P-256 over X25519 for WebCrypto
13
+ // availability in every browser).
14
+ // sender: ephemeral ECDH P-256 keypair per message.
15
+ // KDF: HKDF-SHA256(ikm = ECDH x-coordinate, salt = 32 zero bytes,
16
+ // info = "reefclaw-credential-sealbox-v1" || recipientPub ||
17
+ // ephemeralPub) → 32-byte AES key. Binding both public keys into
18
+ // `info` ties the key to this exact pair.
19
+ // cipher: AES-256-GCM, 12-byte IV, AAD "reefclaw-credentials-v1",
20
+ // 16-byte tag appended to the ciphertext (WebCrypto's layout).
21
+ // envelope: { v:1, kid, epk, iv, ct } — kid = first 16 hex of
22
+ // SHA-256(recipient public key, uncompressed), epk/iv/ct base64.
23
+ //
24
+ // The webapp sealer (webapp/src/lib/crypto/seal-credentials.ts) implements the
25
+ // exact mirror in WebCrypto; a shared fixed test vector in BOTH packages pins
26
+ // the wire format so the two implementations can never drift silently.
27
+ //
28
+ // SECURITY: this module holds the transport PRIVATE key and the decrypted
29
+ // plaintext in memory only. It never logs either, never returns the private
30
+ // key, and the public key is the ONLY thing published (via the readiness
31
+ // report — that is its purpose).
32
+ import { createECDH, createDecipheriv, createHash, hkdfSync } from 'node:crypto';
33
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { dirname, join } from 'node:path';
36
+ import { logger } from '../logger.js';
37
+ const TAG = 'sealed-credentials';
38
+ const CURVE = 'prime256v1'; // P-256
39
+ const KDF_CONTEXT = 'reefclaw-credential-sealbox-v1';
40
+ const AAD = 'reefclaw-credentials-v1';
41
+ const KEY_FILE = 'credential-transport-key.json';
42
+ export class SealedEnvelopeError extends Error {
43
+ reason;
44
+ constructor(message,
45
+ /** 'key_mismatch' → the browser sealed to a stale/foreign key (refresh the
46
+ * dashboard so it refetches this box's current key); 'malformed' → not a
47
+ * v1 envelope; 'decrypt_failed' → tampered or corrupted in transit. */
48
+ reason) {
49
+ super(message);
50
+ this.reason = reason;
51
+ this.name = 'SealedEnvelopeError';
52
+ }
53
+ }
54
+ function keyFilePath(dir) {
55
+ return join(dir ?? join(homedir(), '.reefclaw'), KEY_FILE);
56
+ }
57
+ export function computeKeyId(publicKeyRaw) {
58
+ return createHash('sha256').update(publicKeyRaw).digest('hex').slice(0, 16);
59
+ }
60
+ function loadKeyFile(dir) {
61
+ try {
62
+ const raw = JSON.parse(readFileSync(keyFilePath(dir), 'utf-8'));
63
+ if (raw?.v === 1 &&
64
+ raw.curve === 'p256' &&
65
+ typeof raw.privateKeyHex === 'string' &&
66
+ /^[0-9a-f]{64}$/i.test(raw.privateKeyHex) &&
67
+ typeof raw.publicKeyHex === 'string' &&
68
+ /^04[0-9a-f]{128}$/i.test(raw.publicKeyHex)) {
69
+ return raw;
70
+ }
71
+ }
72
+ catch {
73
+ // Missing or unreadable — caller generates.
74
+ }
75
+ return null;
76
+ }
77
+ /** Ensure the box's static transport keypair exists (generate + persist 0600 on
78
+ * first run) and return the PUBLIC half for the readiness report. Never
79
+ * returns or logs the private half. */
80
+ export function ensureCredentialTransportKey(dir) {
81
+ const existing = loadKeyFile(dir);
82
+ if (existing) {
83
+ const pub = Buffer.from(existing.publicKeyHex, 'hex');
84
+ return { publicKeyBase64: pub.toString('base64'), keyId: computeKeyId(pub) };
85
+ }
86
+ const ecdh = createECDH(CURVE);
87
+ ecdh.generateKeys();
88
+ const pub = ecdh.getPublicKey(); // uncompressed 65 bytes
89
+ const file = {
90
+ v: 1,
91
+ curve: 'p256',
92
+ privateKeyHex: ecdh.getPrivateKey().toString('hex').padStart(64, '0'),
93
+ publicKeyHex: pub.toString('hex'),
94
+ };
95
+ const path = keyFilePath(dir);
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ writeFileSync(path, JSON.stringify(file), { mode: 0o600 });
98
+ try {
99
+ chmodSync(path, 0o600); // writeFileSync mode is ignored on some platforms
100
+ }
101
+ catch {
102
+ // Windows dev box — best effort; prod boxes are Linux.
103
+ }
104
+ logger.info(TAG, `Generated credential transport key (keyId ${computeKeyId(pub)})`);
105
+ return { publicKeyBase64: pub.toString('base64'), keyId: computeKeyId(pub) };
106
+ }
107
+ /** The PUBLIC half for the readiness report, or null when no key exists yet
108
+ * (never generates — boot calls ensureCredentialTransportKey first). */
109
+ export function getCredentialTransportPublicKey(dir) {
110
+ const file = loadKeyFile(dir);
111
+ if (!file)
112
+ return null;
113
+ const pub = Buffer.from(file.publicKeyHex, 'hex');
114
+ return { publicKeyBase64: pub.toString('base64'), keyId: computeKeyId(pub) };
115
+ }
116
+ function b64(value, name) {
117
+ if (typeof value !== 'string' || value.length === 0) {
118
+ throw new SealedEnvelopeError(`sealed envelope missing ${name}`, 'malformed');
119
+ }
120
+ const buf = Buffer.from(value, 'base64');
121
+ if (buf.length === 0)
122
+ throw new SealedEnvelopeError(`sealed envelope ${name} is not base64`, 'malformed');
123
+ return buf;
124
+ }
125
+ /** Derive the AES key exactly as the browser sealer does — pinned by the
126
+ * cross-package fixed vector. */
127
+ function deriveAesKey(shared, recipientPub, ephemeralPub) {
128
+ const info = Buffer.concat([Buffer.from(KDF_CONTEXT, 'utf8'), recipientPub, ephemeralPub]);
129
+ return Buffer.from(hkdfSync('sha256', shared, Buffer.alloc(32), info, 32));
130
+ }
131
+ /** Open a sealed credential envelope with this box's private transport key.
132
+ * Returns the decrypted JSON payload (plain object). Throws
133
+ * SealedEnvelopeError with a caller-renderable reason on any failure. */
134
+ export function unsealCredentials(envelope, dir) {
135
+ if (!envelope || typeof envelope !== 'object') {
136
+ throw new SealedEnvelopeError('sealed payload must be an object', 'malformed');
137
+ }
138
+ const env = envelope;
139
+ if (env.v !== 1)
140
+ throw new SealedEnvelopeError('unsupported sealed envelope version', 'malformed');
141
+ const file = loadKeyFile(dir);
142
+ if (!file) {
143
+ throw new SealedEnvelopeError('this box has no credential transport key yet — restart the agent, then refresh the dashboard', 'key_mismatch');
144
+ }
145
+ const ourPub = Buffer.from(file.publicKeyHex, 'hex');
146
+ if (typeof env.kid !== 'string' || env.kid !== computeKeyId(ourPub)) {
147
+ throw new SealedEnvelopeError('the dashboard encrypted to a different key than this box holds — refresh the dashboard page and try again', 'key_mismatch');
148
+ }
149
+ const epk = b64(env.epk, 'epk');
150
+ const iv = b64(env.iv, 'iv');
151
+ const ct = b64(env.ct, 'ct');
152
+ if (epk.length !== 65 || epk[0] !== 0x04) {
153
+ throw new SealedEnvelopeError('sealed envelope epk is not an uncompressed P-256 point', 'malformed');
154
+ }
155
+ if (iv.length !== 12 || ct.length <= 16) {
156
+ throw new SealedEnvelopeError('sealed envelope iv/ct malformed', 'malformed');
157
+ }
158
+ try {
159
+ const ecdh = createECDH(CURVE);
160
+ ecdh.setPrivateKey(Buffer.from(file.privateKeyHex, 'hex'));
161
+ const shared = ecdh.computeSecret(epk);
162
+ const key = deriveAesKey(shared, ourPub, epk);
163
+ const tag = ct.subarray(ct.length - 16);
164
+ const body = ct.subarray(0, ct.length - 16);
165
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
166
+ decipher.setAAD(Buffer.from(AAD, 'utf8'));
167
+ decipher.setAuthTag(tag);
168
+ const plain = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
169
+ const parsed = JSON.parse(plain);
170
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
171
+ throw new SealedEnvelopeError('sealed payload did not contain an object', 'decrypt_failed');
172
+ }
173
+ return parsed;
174
+ }
175
+ catch (err) {
176
+ if (err instanceof SealedEnvelopeError)
177
+ throw err;
178
+ throw new SealedEnvelopeError('could not decrypt the sealed credentials — the payload was corrupted in transit; try again', 'decrypt_failed');
179
+ }
180
+ }
@@ -11,7 +11,7 @@
11
11
  // - Always de-escalates to PAPER on mode rollback; never stays in a
12
12
  // live mode without credentials (would crash the adapter on first
13
13
  // order anyway).
14
- import { updatePluginConfig } from '../config/plugin-config-io.js';
14
+ import { readPluginConfig, updatePluginConfig } from '../config/plugin-config-io.js';
15
15
  import { logger } from '../logger.js';
16
16
  const TAG = 'clear-exchange-credentials';
17
17
  export async function clearExchangeCredentialsTool(args, deps) {
@@ -26,6 +26,16 @@ export async function clearExchangeCredentialsTool(args, deps) {
26
26
  readiness: deps.runtime.adapter.readiness,
27
27
  };
28
28
  }
29
+ // Clearing removes the whole `exchange` block INCLUDING `venue` — a
30
+ // hyperliquid box reverts to the binance default paper stack on its next
31
+ // restart, so the outcome message says so (the paper wiring is boot-time).
32
+ let clearedVenueWasHyperliquid = false;
33
+ try {
34
+ clearedVenueWasHyperliquid = readPluginConfig(deps.configPath).exchange?.venue === 'hyperliquid';
35
+ }
36
+ catch {
37
+ // Unreadable config — nothing venue-specific to report.
38
+ }
29
39
  try {
30
40
  updatePluginConfig({ exchange: undefined }, deps.configPath);
31
41
  }
@@ -57,11 +67,14 @@ export async function clearExchangeCredentialsTool(args, deps) {
57
67
  await deps.runtime.reconnect({ mode: 'PAPER', exchange: null }, { adapterDeps: deps.adapterDeps });
58
68
  rolledBackToPaper = true;
59
69
  }
70
+ const venueNote = clearedVenueWasHyperliquid
71
+ ? ' (venue selection cleared too — the box reverts to Binance paper data on its next restart)'
72
+ : '';
60
73
  return {
61
74
  ok: true,
62
75
  message: rolledBackToPaper
63
- ? `Credentials cleared and trading mode rolled back from ${modeBefore} to PAPER`
64
- : 'Credentials cleared',
76
+ ? `Credentials cleared and trading mode rolled back from ${modeBefore} to PAPER${venueNote}`
77
+ : `Credentials cleared${venueNote}`,
65
78
  modeBefore,
66
79
  mode: deps.runtime.mode,
67
80
  rolledBackToPaper,
@@ -0,0 +1,29 @@
1
+ import type { VenueId } from '../venues/registry.js';
2
+ export interface HlAgentWalletStatusArgs {
3
+ /** Tool-discovery probe — return immediately, no config/network access. */
4
+ probe?: boolean;
5
+ }
6
+ export interface HlAgentWalletStatusResult {
7
+ ok: boolean;
8
+ message: string;
9
+ /** False until an HL wallet (venue + agent key) exists in plugin-config. */
10
+ configured: boolean;
11
+ masterAddress?: string;
12
+ /** Derived from the stored key (public). */
13
+ agentAddress?: string;
14
+ testnet?: boolean;
15
+ /** true/false = definitive verdict from the exchange; null = unverifiable. */
16
+ approved?: boolean | null;
17
+ /** Approval expiry (epoch ms) when the exchange reports one. */
18
+ validUntil?: number | null;
19
+ balanceUSDC?: number | null;
20
+ warnings?: string[];
21
+ /** True when the booted venue is not hyperliquid yet — restart pending. */
22
+ restartRequired?: boolean;
23
+ }
24
+ export interface HlAgentWalletStatusDeps {
25
+ bootVenue?: VenueId;
26
+ configPath?: string;
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+ export declare function hlAgentWalletStatusTool(args: HlAgentWalletStatusArgs, deps?: HlAgentWalletStatusDeps): Promise<HlAgentWalletStatusResult>;
@@ -0,0 +1,100 @@
1
+ // hl_agent_wallet_status — operator-only plugin tool.
2
+ //
3
+ // The polling half of the guided Hyperliquid onboarding: after
4
+ // hl_provision_agent_wallet mints a key and the operator signs the
5
+ // approveAgent approval in their browser, the dashboard polls THIS tool until
6
+ // Hyperliquid lists the box's agent address as approved. All reads are the
7
+ // keyless shared preflight (hl-preflight.ts) — the stored private key is only
8
+ // ever used here to DERIVE its public address, never to sign, and never
9
+ // appears in the result.
10
+ //
11
+ // `{ probe: true }` short-circuits before touching config or network — the
12
+ // bridge's tool-discovery probes every optional tool at boot, and a probe
13
+ // that ran the real preflight would put 2 HL /info calls on every discovery
14
+ // retry cycle (and could time the 8s probe out entirely → the tool would be
15
+ // "missing" forever; see tool-discovery.ts).
16
+ import { readPluginConfig } from '../config/plugin-config-io.js';
17
+ import { deriveAddressFromPrivateKey } from '../venues/hyperliquid/hl-agent-wallet.js';
18
+ import { hlPreflight } from '../venues/hyperliquid/hl-preflight.js';
19
+ import { logger } from '../logger.js';
20
+ const TAG = 'hl-agent-wallet-status';
21
+ export async function hlAgentWalletStatusTool(args, deps = {}) {
22
+ if (args?.probe === true) {
23
+ return { ok: false, message: 'probe', configured: false };
24
+ }
25
+ let masterAddress;
26
+ let storedKey;
27
+ let testnet = false;
28
+ try {
29
+ const cfg = readPluginConfig(deps.configPath);
30
+ if (cfg.exchange?.venue === 'hyperliquid' && typeof cfg.exchange.agentPrivateKey === 'string') {
31
+ storedKey = cfg.exchange.agentPrivateKey;
32
+ masterAddress =
33
+ typeof cfg.exchange.walletAddress === 'string' ? cfg.exchange.walletAddress : undefined;
34
+ testnet = cfg.exchange.testnet === true;
35
+ }
36
+ }
37
+ catch (err) {
38
+ logger.warn(TAG, `config read failed: ${err instanceof Error ? err.message : String(err)}`);
39
+ }
40
+ if (!storedKey || !masterAddress) {
41
+ return {
42
+ ok: true,
43
+ message: 'No Hyperliquid agent wallet is provisioned on this machine yet.',
44
+ configured: false,
45
+ };
46
+ }
47
+ const restartRequired = (deps.bootVenue ?? 'binance') !== 'hyperliquid';
48
+ const derived = await deriveAddressFromPrivateKey(storedKey);
49
+ if (!derived.ok) {
50
+ return {
51
+ ok: true,
52
+ message: 'A wallet is stored but its address could not be derived on this host — approval not verifiable.',
53
+ configured: true,
54
+ masterAddress,
55
+ testnet,
56
+ approved: null,
57
+ validUntil: null,
58
+ restartRequired,
59
+ warnings: ['Could not derive the agent wallet address on this host — approval not verified.'],
60
+ };
61
+ }
62
+ const pre = await hlPreflight({
63
+ walletAddress: masterAddress,
64
+ agentAddress: derived.address,
65
+ testnet,
66
+ fetchImpl: deps.fetchImpl,
67
+ });
68
+ if (!pre.reachable) {
69
+ return {
70
+ ok: false,
71
+ message: `Could not reach Hyperliquid to check the approval: ${pre.unreachableError ?? 'unknown error'}`,
72
+ configured: true,
73
+ masterAddress,
74
+ agentAddress: derived.address,
75
+ testnet,
76
+ approved: null,
77
+ validUntil: null,
78
+ restartRequired,
79
+ warnings: pre.warnings,
80
+ };
81
+ }
82
+ const approvedText = pre.agentApproved === true
83
+ ? 'approved'
84
+ : pre.agentApproved === false
85
+ ? 'NOT approved yet'
86
+ : 'approval not verifiable';
87
+ return {
88
+ ok: true,
89
+ message: `Agent wallet ${derived.address.slice(0, 8)}… is ${approvedText}.`,
90
+ configured: true,
91
+ masterAddress,
92
+ agentAddress: derived.address,
93
+ testnet,
94
+ approved: pre.agentApproved,
95
+ validUntil: pre.agentValidUntil,
96
+ balanceUSDC: pre.balanceUSDC,
97
+ warnings: pre.warnings,
98
+ restartRequired,
99
+ };
100
+ }
@@ -0,0 +1,35 @@
1
+ import type { PluginRuntime } from '../onboarding/runtime.js';
2
+ import type { TradingMode } from '../types.js';
3
+ import type { VenueId } from '../venues/registry.js';
4
+ export interface HlProvisionAgentWalletArgs {
5
+ /** MASTER account address (public, 0x…) the agent wallet will trade for. */
6
+ walletAddress?: string;
7
+ testnet?: boolean;
8
+ /** Mint a fresh key even when one exists (PAPER mode only). */
9
+ regenerate?: boolean;
10
+ }
11
+ export interface HlProvisionAgentWalletResult {
12
+ ok: boolean;
13
+ message: string;
14
+ venue: 'hyperliquid';
15
+ /** Derived agent address (public) — what the operator approves on HL. */
16
+ agentAddress?: string;
17
+ /** Master address the stored credentials are bound to. */
18
+ masterAddress?: string;
19
+ testnet?: boolean;
20
+ /** True when an already-provisioned wallet was returned instead of minting. */
21
+ existing?: boolean;
22
+ /** Existing path only: does the stored master match the requested one? */
23
+ masterMatches?: boolean;
24
+ /** True when the boot venue differs — restart applies the venue. */
25
+ restartRequired?: boolean;
26
+ mode: TradingMode;
27
+ }
28
+ export interface HlProvisionAgentWalletDeps {
29
+ runtime: PluginRuntime;
30
+ /** The venue this process booted with. */
31
+ bootVenue?: VenueId;
32
+ /** Override the config file path — tests use this. */
33
+ configPath?: string;
34
+ }
35
+ export declare function hlProvisionAgentWalletTool(args: HlProvisionAgentWalletArgs, deps: HlProvisionAgentWalletDeps): Promise<HlProvisionAgentWalletResult>;
@@ -0,0 +1,144 @@
1
+ // hl_provision_agent_wallet — operator-only plugin tool.
2
+ //
3
+ // The zero-secret-handling Hyperliquid onboarding path: the agent-wallet
4
+ // PRIVATE KEY IS BORN ON THIS BOX. This tool generates a fresh secp256k1
5
+ // keypair locally, persists it straight into ~/.reefclaw/plugin-config.json
6
+ // (venue=hyperliquid, wholesale exchange-block write — same contract as
7
+ // set_exchange_credentials), and returns ONLY the derived PUBLIC address.
8
+ // The dashboard then walks the operator through approving that address with
9
+ // one wallet signature (Hyperliquid `approveAgent` — user-signed, submitted
10
+ // from the browser straight to HL); hl_agent_wallet_status polls until the
11
+ // approval lands. Nobody — not the browser, not the relay, not ReefClaw, not
12
+ // even the operator — ever sees the private key.
13
+ //
14
+ // Semantics:
15
+ // - IDEMPOTENT by default: if an HL agent key already exists in config the
16
+ // tool returns its derived address (existing:true) instead of minting a
17
+ // new one — a closed-and-reopened dashboard resumes where it left off.
18
+ // - `regenerate:true` mints a fresh key (the old one's approval becomes
19
+ // orphaned on HL; the UI warns first). REFUSED while trading mode is not
20
+ // PAPER — replacing the signing key under a live book breaks the next
21
+ // restart until the new key is approved.
22
+ // - A venue CHANGE (boot venue ≠ hyperliquid) is restart-required, exactly
23
+ // like set_exchange_credentials — provisioning + approval both work
24
+ // pre-restart (approval polling is keyless), so the restart is the LAST
25
+ // step of the guided flow, not a blocker in the middle.
26
+ //
27
+ // Security: the private key exists only in this process's memory between
28
+ // generation and the config write. It is never logged, never in the result,
29
+ // never egressed (credential-no-egress.test.ts pins this module).
30
+ import { readPluginConfig, updatePluginConfig, redactCredential } from '../config/plugin-config-io.js';
31
+ import { deriveAddressFromPrivateKey, generateAgentPrivateKey, isHexAddress, } from '../venues/hyperliquid/hl-agent-wallet.js';
32
+ import { logger } from '../logger.js';
33
+ const TAG = 'hl-provision-agent-wallet';
34
+ export async function hlProvisionAgentWalletTool(args, deps) {
35
+ const bootVenue = deps.bootVenue ?? 'binance';
36
+ const mode = deps.runtime.mode;
37
+ const restartRequired = bootVenue !== 'hyperliquid';
38
+ if (!isHexAddress(args?.walletAddress)) {
39
+ return {
40
+ ok: false,
41
+ message: 'walletAddress must be your MASTER account address: 0x followed by 40 hex characters',
42
+ venue: 'hyperliquid',
43
+ mode,
44
+ };
45
+ }
46
+ const walletAddress = args.walletAddress.trim();
47
+ const testnet = args.testnet === true;
48
+ // Existing-wallet path: resume, don't clobber.
49
+ let existingKey = null;
50
+ let existingMaster = null;
51
+ let existingTestnet = false;
52
+ try {
53
+ const cfg = readPluginConfig(deps.configPath);
54
+ if (cfg.exchange?.venue === 'hyperliquid' && typeof cfg.exchange.agentPrivateKey === 'string') {
55
+ existingKey = cfg.exchange.agentPrivateKey;
56
+ existingMaster = typeof cfg.exchange.walletAddress === 'string' ? cfg.exchange.walletAddress : null;
57
+ existingTestnet = cfg.exchange.testnet === true;
58
+ }
59
+ }
60
+ catch {
61
+ // Unreadable config — treat as fresh.
62
+ }
63
+ if (existingKey && args.regenerate !== true) {
64
+ const derived = await deriveAddressFromPrivateKey(existingKey);
65
+ if (derived.ok) {
66
+ const masterMatches = existingMaster != null && existingMaster.toLowerCase() === walletAddress.toLowerCase();
67
+ return {
68
+ ok: true,
69
+ message: masterMatches
70
+ ? 'An agent wallet is already provisioned for this master account — approve it if you have not yet.'
71
+ : `An agent wallet is already provisioned for a DIFFERENT master account (${redactCredential(existingMaster ?? '')}). ` +
72
+ 'Pass regenerate: true to replace it (its approval will be orphaned).',
73
+ venue: 'hyperliquid',
74
+ agentAddress: derived.address,
75
+ masterAddress: existingMaster ?? undefined,
76
+ testnet: existingTestnet,
77
+ existing: true,
78
+ masterMatches,
79
+ restartRequired,
80
+ mode,
81
+ };
82
+ }
83
+ // Stored key is corrupt/underivable — fall through to mint a fresh one
84
+ // (regenerate semantics: the stored key was never usable anyway).
85
+ logger.warn(TAG, 'Stored agent key is not derivable — provisioning a fresh one');
86
+ }
87
+ if (existingKey && args.regenerate === true && mode !== 'PAPER') {
88
+ return {
89
+ ok: false,
90
+ message: `Refusing to replace the agent key while trading mode is ${mode} — go to PAPER first ` +
91
+ '(a fresh key is unapproved and would leave the next restart unable to sign).',
92
+ venue: 'hyperliquid',
93
+ mode,
94
+ };
95
+ }
96
+ // Mint. The key exists in memory only between these two statements.
97
+ const agentPrivateKey = generateAgentPrivateKey();
98
+ const derived = await deriveAddressFromPrivateKey(agentPrivateKey);
99
+ if (!derived.ok) {
100
+ // Without the derived address the guided flow cannot proceed (nothing to
101
+ // approve). Fail cleanly toward the manual path.
102
+ return {
103
+ ok: false,
104
+ message: 'Could not derive the agent wallet address on this host — use the manual setup instead ' +
105
+ '(generate the agent wallet on the Hyperliquid API page and paste the two values).',
106
+ venue: 'hyperliquid',
107
+ mode,
108
+ };
109
+ }
110
+ const exchange = {
111
+ venue: 'hyperliquid',
112
+ walletAddress,
113
+ agentPrivateKey,
114
+ testnet,
115
+ };
116
+ try {
117
+ updatePluginConfig({ exchange }, deps.configPath);
118
+ }
119
+ catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ logger.error(TAG, `Failed to persist provisioned wallet: ${msg}`);
122
+ return {
123
+ ok: false,
124
+ message: `Failed to persist the provisioned wallet: ${msg}`,
125
+ venue: 'hyperliquid',
126
+ mode,
127
+ };
128
+ }
129
+ logger.info(TAG, `Provisioned agent wallet ${derived.address} for master ${redactCredential(walletAddress)} ` +
130
+ `(testnet: ${testnet}${restartRequired ? ', restart required to apply venue' : ''})`);
131
+ return {
132
+ ok: true,
133
+ message: restartRequired
134
+ ? 'Agent wallet created on this machine. Approve it on Hyperliquid, then restart your agent to switch the venue.'
135
+ : 'Agent wallet created on this machine. Approve it on Hyperliquid to finish.',
136
+ venue: 'hyperliquid',
137
+ agentAddress: derived.address,
138
+ masterAddress: walletAddress,
139
+ testnet,
140
+ existing: false,
141
+ restartRequired,
142
+ mode,
143
+ };
144
+ }
@@ -1,11 +1,30 @@
1
1
  import type { PluginRuntime } from '../onboarding/runtime.js';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
3
  import type { TradingMode } from '../types.js';
4
+ import { type VenueId } from '../venues/registry.js';
4
5
  export interface SetExchangeCredentialsArgs {
5
- apiKey: string;
6
- secret: string;
6
+ /** Absent → 'binance' (legacy callers unchanged). */
7
+ venue?: string;
8
+ apiKey?: string;
9
+ secret?: string;
10
+ walletAddress?: string;
11
+ agentPrivateKey?: string;
7
12
  testnet?: boolean;
13
+ /** End-to-end encrypted envelope from the dashboard (sealed-credentials.ts).
14
+ * When present it is the SOLE source of the credential fields — any
15
+ * plaintext siblings are ignored, so a mixed payload can't smuggle values
16
+ * past the encryption. */
17
+ sealed?: unknown;
8
18
  }
19
+ /** Open a sealed envelope into plain args, or return a renderable error. */
20
+ export declare function resolveSealedArgs<T extends {
21
+ sealed?: unknown;
22
+ }>(args: T, configDir?: string): {
23
+ args: T;
24
+ sealed: boolean;
25
+ } | {
26
+ error: string;
27
+ };
9
28
  export interface SetExchangeCredentialsResult {
10
29
  ok: boolean;
11
30
  message: string;
@@ -13,13 +32,23 @@ export interface SetExchangeCredentialsResult {
13
32
  reconnected: boolean;
14
33
  mode: TradingMode;
15
34
  readiness: string;
35
+ /** Venue the credentials were stored for. */
36
+ venue?: VenueId;
37
+ /** True when the stored venue differs from the booted venue — the operator
38
+ * must restart the agent before the change takes effect. */
39
+ restartRequired?: boolean;
16
40
  }
17
41
  export interface SetExchangeCredentialsDeps {
18
42
  runtime: PluginRuntime;
19
43
  adapterDeps: {
20
44
  adapter: IExchangeAdapter;
21
45
  };
46
+ /** The venue this process booted with (register()-time). Venue changes are
47
+ * restart-required; absent (legacy tests) → 'binance'. */
48
+ bootVenue?: VenueId;
22
49
  /** Override the config file path — tests use this. */
23
50
  configPath?: string;
51
+ /** Override the transport-key directory (sealed envelopes) — tests use this. */
52
+ transportKeyDir?: string;
24
53
  }
25
- export declare function setExchangeCredentialsTool(args: SetExchangeCredentialsArgs, deps: SetExchangeCredentialsDeps): Promise<SetExchangeCredentialsResult>;
54
+ export declare function setExchangeCredentialsTool(rawArgs: SetExchangeCredentialsArgs, deps: SetExchangeCredentialsDeps): Promise<SetExchangeCredentialsResult>;