@reefclaw/openclaw-plugin 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/bridge.d.ts +10 -0
- package/bridge/bridge.js +142 -22
- package/bridge/gateway/gateway-ws-client.d.ts +7 -1
- package/bridge/gateway/gateway-ws-client.js +31 -8
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +11 -0
- package/bridge/provider.d.ts +73 -6
- package/bridge/providers/gateway.d.ts +12 -3
- package/bridge/providers/gateway.js +18 -5
- package/bridge/providers/onboarding-commands.d.ts +33 -7
- package/bridge/providers/onboarding-commands.js +109 -13
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +6 -0
- package/config/tool-gate.js +4 -0
- package/index.js +77 -12
- package/ingest/position-auto-capture.js +19 -0
- package/ingest/readiness-reporter.js +25 -1
- package/openclaw.plugin.json +3 -1
- package/package.json +38 -38
- package/security/sealed-credentials.d.ts +38 -0
- package/security/sealed-credentials.js +180 -0
- package/tools/clear-exchange-credentials.js +16 -3
- package/tools/hl-agent-wallet-status.d.ts +29 -0
- package/tools/hl-agent-wallet-status.js +100 -0
- package/tools/hl-provision-agent-wallet.d.ts +35 -0
- package/tools/hl-provision-agent-wallet.js +144 -0
- package/tools/set-exchange-credentials.d.ts +32 -3
- package/tools/set-exchange-credentials.js +144 -32
- package/tools/set-trading-mode.d.ts +7 -0
- package/tools/set-trading-mode.js +15 -0
- package/tools/test-exchange-credentials.d.ts +30 -3
- package/tools/test-exchange-credentials.js +173 -35
- package/types.d.ts +10 -11
- package/venues/hyperliquid/hl-agent-wallet.d.ts +29 -0
- package/venues/hyperliquid/hl-agent-wallet.js +118 -0
- package/venues/hyperliquid/hl-preflight.d.ts +28 -0
- package/venues/hyperliquid/hl-preflight.js +116 -0
|
@@ -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
|
-
:
|
|
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
|
-
|
|
6
|
-
|
|
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(
|
|
54
|
+
export declare function setExchangeCredentialsTool(rawArgs: SetExchangeCredentialsArgs, deps: SetExchangeCredentialsDeps): Promise<SetExchangeCredentialsResult>;
|