@reefclaw/connect 0.1.26 → 0.1.28

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 (39) hide show
  1. package/assets/bridge/bridge.d.ts +10 -0
  2. package/assets/bridge/bridge.js +142 -22
  3. package/assets/bridge/gateway/gateway-ws-client.d.ts +7 -1
  4. package/assets/bridge/gateway/gateway-ws-client.js +31 -8
  5. package/assets/bridge/gateway/tool-discovery.d.ts +1 -1
  6. package/assets/bridge/gateway/tool-discovery.js +11 -0
  7. package/assets/bridge/provider.d.ts +73 -6
  8. package/assets/bridge/providers/gateway.d.ts +12 -3
  9. package/assets/bridge/providers/gateway.js +18 -5
  10. package/assets/bridge/providers/onboarding-commands.d.ts +33 -7
  11. package/assets/bridge/providers/onboarding-commands.js +109 -13
  12. package/assets/bridge/types.d.ts +1 -1
  13. package/assets/bridge/types.js +5 -0
  14. package/assets/plugin/ccxt/binance-private.js +6 -0
  15. package/assets/plugin/config/tool-gate.js +4 -0
  16. package/assets/plugin/index.js +77 -12
  17. package/assets/plugin/ingest/position-auto-capture.js +19 -0
  18. package/assets/plugin/ingest/readiness-reporter.js +25 -1
  19. package/assets/plugin/openclaw.plugin.json +3 -1
  20. package/assets/plugin/security/sealed-credentials.d.ts +38 -0
  21. package/assets/plugin/security/sealed-credentials.js +180 -0
  22. package/assets/plugin/tools/clear-exchange-credentials.js +16 -3
  23. package/assets/plugin/tools/hl-agent-wallet-status.d.ts +29 -0
  24. package/assets/plugin/tools/hl-agent-wallet-status.js +100 -0
  25. package/assets/plugin/tools/hl-provision-agent-wallet.d.ts +35 -0
  26. package/assets/plugin/tools/hl-provision-agent-wallet.js +144 -0
  27. package/assets/plugin/tools/set-exchange-credentials.d.ts +32 -3
  28. package/assets/plugin/tools/set-exchange-credentials.js +144 -32
  29. package/assets/plugin/tools/set-trading-mode.d.ts +7 -0
  30. package/assets/plugin/tools/set-trading-mode.js +15 -0
  31. package/assets/plugin/tools/test-exchange-credentials.d.ts +30 -3
  32. package/assets/plugin/tools/test-exchange-credentials.js +173 -35
  33. package/assets/plugin/types.d.ts +10 -11
  34. package/assets/plugin/venues/hyperliquid/hl-agent-wallet.d.ts +29 -0
  35. package/assets/plugin/venues/hyperliquid/hl-agent-wallet.js +118 -0
  36. package/assets/plugin/venues/hyperliquid/hl-preflight.d.ts +28 -0
  37. package/assets/plugin/venues/hyperliquid/hl-preflight.js +116 -0
  38. package/package.json +32 -32
  39. package/dist/daemon.js +0 -104
@@ -0,0 +1,118 @@
1
+ // Hyperliquid agent-wallet helpers — format validation + address derivation.
2
+ //
3
+ // Why derivation exists at all: the single most catastrophic mistake a
4
+ // non-technical trader can make in the HL onboarding form is pasting their
5
+ // MASTER wallet's private key where the AGENT key belongs (the master key can
6
+ // withdraw everything; the agent key can only trade). The form cannot tell the
7
+ // two apart — both are 32-byte hex — but the box can: derive the address from
8
+ // the pasted key and refuse when it equals the master walletAddress. The same
9
+ // derived address also lets test_exchange_credentials verify the agent is
10
+ // actually APPROVED for the master account (POST /info extraAgents) before the
11
+ // user goes live.
12
+ //
13
+ // Derivation = secp256k1 pubkey (node:crypto createECDH — stable Node API) +
14
+ // keccak-256 of the 64-byte public key, last 20 bytes (the Ethereum address
15
+ // rule). Node has no keccak (its sha3-256 is NIST-padded, a different hash),
16
+ // so keccak_256 comes from ccxt's vendored noble-hashes — ccxt is pinned at
17
+ // 4.5.37 and the deep path is guarded by a known-vector unit test
18
+ // (hl-agent-wallet.test.ts), so a ccxt bump that moves the file fails loudly.
19
+ // If the import ever breaks at runtime we degrade gracefully: derivation
20
+ // reports 'unavailable' and callers skip the address checks rather than
21
+ // blocking credential entry.
22
+ //
23
+ // SECURITY: this module handles the raw agent private key in memory only —
24
+ // it never logs it, never persists it, and never puts it in a return value.
25
+ import { createECDH, randomBytes } from 'node:crypto';
26
+ import { createRequire } from 'node:module';
27
+ import { pathToFileURL } from 'node:url';
28
+ import path from 'node:path';
29
+ /** secp256k1 group order n — a private key must be in [1, n-1]. */
30
+ const SECP256K1_ORDER = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
31
+ /** Generate a fresh secp256k1 private key (0x-prefixed, 64 hex chars) from
32
+ * CSPRNG bytes, rejection-sampled into the valid scalar range. This is how
33
+ * the box mints its own HL agent wallet — the key is BORN here and never
34
+ * leaves the machine (the caller persists it to plugin-config and surfaces
35
+ * only the derived PUBLIC address). */
36
+ export function generateAgentPrivateKey() {
37
+ // Rejection probability per draw is ~2^-128 (order ≈ 2^256) — the loop is
38
+ // for correctness, not because retries are expected.
39
+ for (;;) {
40
+ const candidate = randomBytes(32);
41
+ const scalar = BigInt(`0x${candidate.toString('hex')}`);
42
+ if (scalar > 0n && scalar < SECP256K1_ORDER) {
43
+ return `0x${candidate.toString('hex')}`;
44
+ }
45
+ }
46
+ }
47
+ /** Master/agent wallet address: 0x + 40 hex chars. */
48
+ export function isHexAddress(value) {
49
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim());
50
+ }
51
+ /** Agent private key: 32 bytes of hex, 0x prefix optional (the HL app shows it
52
+ * with the prefix; some wallets export without). */
53
+ export function isHexPrivateKey(value) {
54
+ return typeof value === 'string' && /^(0x)?[0-9a-fA-F]{64}$/.test(value.trim());
55
+ }
56
+ /** Canonical stored form: trimmed, 0x-prefixed, original casing preserved. */
57
+ export function normalizeHexPrivateKey(value) {
58
+ const trimmed = value.trim();
59
+ return trimmed.startsWith('0x') || trimmed.startsWith('0X') ? `0x${trimmed.slice(2)}` : `0x${trimmed}`;
60
+ }
61
+ let keccakPromise = null;
62
+ /** Load keccak_256 from ccxt's vendored noble-hashes. The package's `exports`
63
+ * map only exposes ".", so we resolve the package root from the main entry and
64
+ * import the file by path (file URLs bypass the exports map). Returns null on
65
+ * any failure — derivation then reports 'unavailable'. */
66
+ function loadKeccak() {
67
+ if (!keccakPromise) {
68
+ keccakPromise = (async () => {
69
+ try {
70
+ const req = createRequire(import.meta.url);
71
+ const entry = req.resolve('ccxt');
72
+ const marker = `${path.sep}ccxt${path.sep}`;
73
+ const idx = entry.lastIndexOf(marker);
74
+ if (idx === -1)
75
+ return null;
76
+ const root = entry.slice(0, idx + marker.length - 1);
77
+ const sha3 = path.join(root, 'js', 'src', 'static_dependencies', 'noble-hashes', 'sha3.js');
78
+ const mod = (await import(pathToFileURL(sha3).href));
79
+ return typeof mod.keccak_256 === 'function' ? mod.keccak_256 : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ })();
85
+ }
86
+ return keccakPromise;
87
+ }
88
+ /** Test seam: reset the memoized keccak loader (unit tests only). */
89
+ export function __resetKeccakCacheForTests() {
90
+ keccakPromise = null;
91
+ }
92
+ /** Derive the Ethereum address a private key controls (lowercase 0x…40 hex).
93
+ * 'invalid_key' = the key is malformed or outside the secp256k1 range;
94
+ * 'unavailable' = the keccak dependency could not be loaded (callers must
95
+ * fail OPEN — skip the check, never block credential entry on infra). */
96
+ export async function deriveAddressFromPrivateKey(privateKey) {
97
+ if (!isHexPrivateKey(privateKey))
98
+ return { ok: false, reason: 'invalid_key' };
99
+ const keccak = await loadKeccak();
100
+ if (!keccak)
101
+ return { ok: false, reason: 'unavailable' };
102
+ try {
103
+ const ecdh = createECDH('secp256k1');
104
+ ecdh.setPrivateKey(Buffer.from(normalizeHexPrivateKey(privateKey).slice(2), 'hex'));
105
+ const uncompressed = ecdh.getPublicKey(null, 'uncompressed'); // 0x04 || X || Y (65 bytes)
106
+ const digest = keccak(Uint8Array.from(uncompressed.subarray(1)));
107
+ const address = `0x${Buffer.from(digest.subarray(digest.length - 20)).toString('hex')}`;
108
+ return { ok: true, address };
109
+ }
110
+ catch {
111
+ // createECDH throws on out-of-range keys — malformed input, not infra.
112
+ return { ok: false, reason: 'invalid_key' };
113
+ }
114
+ }
115
+ /** Case-insensitive address equality (addresses may arrive checksummed). */
116
+ export function sameAddress(a, b) {
117
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
118
+ }
@@ -0,0 +1,28 @@
1
+ /** Approvals expiring inside this window get a renewal warning — long enough
2
+ * to act on, short enough not to nag for months. */
3
+ export declare const HL_EXPIRY_WARN_MS: number;
4
+ export interface HlPreflightInput {
5
+ walletAddress: string;
6
+ /** Derived agent address to check approval for; null = skip the check
7
+ * (derivation unavailable). */
8
+ agentAddress: string | null;
9
+ testnet: boolean;
10
+ /** Injectable for tests; production uses global fetch. */
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ export interface HlPreflightOutcome {
14
+ /** clearinghouseState answered and parsed — the master account is queryable. */
15
+ reachable: boolean;
16
+ /** Set when !reachable — the raw error string for the caller's message. */
17
+ unreachableError?: string;
18
+ /** Perp equity + spot USDC; null when unreachable. */
19
+ balanceUSDC: number | null;
20
+ /** true/false = extraAgents answered definitively; null = could not check. */
21
+ agentApproved: boolean | null;
22
+ /** Approval expiry (epoch ms) when the exchange reports one. */
23
+ agentValidUntil: number | null;
24
+ warnings: string[];
25
+ }
26
+ export declare function hlApiBase(testnet: boolean): string;
27
+ export declare function hlInfo(fetchImpl: typeof fetch, testnet: boolean, body: Record<string, unknown>): Promise<unknown>;
28
+ export declare function hlPreflight(input: HlPreflightInput): Promise<HlPreflightOutcome>;
@@ -0,0 +1,116 @@
1
+ // Hyperliquid credential preflight — the UNSIGNED verification core shared by
2
+ // test_exchange_credentials (candidate creds) and hl_agent_wallet_status
3
+ // (stored creds). Deliberately takes ADDRESSES ONLY — this module never sees a
4
+ // private key, so it stays outside the credential-holder set.
5
+ //
6
+ // Checks (all keyless POST /info reads — the master address is the query key):
7
+ // 1. clearinghouseState(master) → perp equity. Failure here = "unreachable";
8
+ // callers hard-fail with the error string.
9
+ // 2. spotClearinghouseState(master) → spot USDC (the unified account
10
+ // collateralizes perps from spot — verified live on testnet 2026-07-12).
11
+ // Cosmetic: failure is silently skipped, perp equity alone still answers.
12
+ // 3. extraAgents(master) → is `agentAddress` in the approved list, and when
13
+ // does the approval expire? Shape [{name,address,validUntil(ms)}] —
14
+ // verified vs Chainstack/QuickNode HL API references 2026-08-01 (the
15
+ // official gitbook omits this info type). A definitive miss is the
16
+ // caller's hard fail; an endpoint error degrades to a warning
17
+ // (fail-open on infra, closed on an explicit "not approved").
18
+ import { sameAddress } from './hl-agent-wallet.js';
19
+ /** Approvals expiring inside this window get a renewal warning — long enough
20
+ * to act on, short enough not to nag for months. */
21
+ export const HL_EXPIRY_WARN_MS = 14 * 24 * 60 * 60 * 1000;
22
+ export function hlApiBase(testnet) {
23
+ return testnet ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
24
+ }
25
+ export async function hlInfo(fetchImpl, testnet, body) {
26
+ const res = await fetchImpl(`${hlApiBase(testnet)}/info`, {
27
+ method: 'POST',
28
+ headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify(body),
30
+ signal: AbortSignal.timeout(15_000),
31
+ });
32
+ if (!res.ok)
33
+ throw new Error(`POST /info ${String(body.type)} → ${res.status}`);
34
+ return res.json();
35
+ }
36
+ function msg(err) {
37
+ return err instanceof Error ? err.message : String(err);
38
+ }
39
+ export async function hlPreflight(input) {
40
+ const fetchImpl = input.fetchImpl ?? fetch;
41
+ const { walletAddress, testnet } = input;
42
+ const warnings = [];
43
+ // 1+2. Master account state.
44
+ let balanceUSDC = null;
45
+ try {
46
+ const state = (await hlInfo(fetchImpl, testnet, {
47
+ type: 'clearinghouseState',
48
+ user: walletAddress,
49
+ }));
50
+ const perpEquity = Number(state?.marginSummary?.accountValue);
51
+ let total = Number.isFinite(perpEquity) ? perpEquity : 0;
52
+ try {
53
+ const spot = (await hlInfo(fetchImpl, testnet, {
54
+ type: 'spotClearinghouseState',
55
+ user: walletAddress,
56
+ }));
57
+ for (const b of spot?.balances ?? []) {
58
+ if (String(b?.coin) === 'USDC') {
59
+ const t = Number(b?.total);
60
+ if (Number.isFinite(t))
61
+ total += t;
62
+ }
63
+ }
64
+ }
65
+ catch {
66
+ // Spot read is cosmetic — perp equity alone is still a real answer.
67
+ }
68
+ balanceUSDC = total;
69
+ if (total <= 0) {
70
+ warnings.push('The master account holds no USDC yet — deposit/bridge USDC to Hyperliquid before going live.');
71
+ }
72
+ }
73
+ catch (e) {
74
+ return {
75
+ reachable: false,
76
+ unreachableError: msg(e),
77
+ balanceUSDC: null,
78
+ agentApproved: null,
79
+ agentValidUntil: null,
80
+ warnings,
81
+ };
82
+ }
83
+ // 3. Agent approval.
84
+ let agentApproved = null;
85
+ let agentValidUntil = null;
86
+ if (input.agentAddress) {
87
+ try {
88
+ const agents = (await hlInfo(fetchImpl, testnet, {
89
+ type: 'extraAgents',
90
+ user: walletAddress,
91
+ }));
92
+ if (Array.isArray(agents)) {
93
+ const match = agents.find((a) => typeof a?.address === 'string' && sameAddress(a.address, input.agentAddress));
94
+ agentApproved = match != null;
95
+ if (match && Number.isFinite(Number(match.validUntil))) {
96
+ agentValidUntil = Number(match.validUntil);
97
+ const remaining = agentValidUntil - Date.now();
98
+ if (remaining <= 0) {
99
+ agentApproved = false;
100
+ warnings.push('The agent wallet approval has EXPIRED — re-approve it in the Hyperliquid API page.');
101
+ }
102
+ else if (remaining < HL_EXPIRY_WARN_MS) {
103
+ warnings.push(`The agent wallet approval expires in ${Math.ceil(remaining / (24 * 60 * 60 * 1000))} day(s) — renew it soon (approvals last at most 180 days).`);
104
+ }
105
+ }
106
+ }
107
+ else {
108
+ warnings.push('Could not read the approved-agents list — approval not verified.');
109
+ }
110
+ }
111
+ catch {
112
+ warnings.push('Could not read the approved-agents list — approval not verified.');
113
+ }
114
+ }
115
+ return { reachable: true, balanceUSDC, agentApproved, agentValidUntil, warnings };
116
+ }
package/package.json CHANGED
@@ -1,32 +1,32 @@
1
- {
2
- "name": "@reefclaw/connect",
3
- "version": "0.1.26",
4
- "description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
5
- "type": "module",
6
- "bin": {
7
- "reefclaw-connect": "dist/cli.js"
8
- },
9
- "files": [
10
- "dist",
11
- "assets"
12
- ],
13
- "engines": {
14
- "node": ">=20"
15
- },
16
- "scripts": {
17
- "bundle-assets": "node scripts/bundle-assets.mjs",
18
- "build": "tsc && node scripts/bundle-assets.mjs",
19
- "test": "vitest",
20
- "test:run": "vitest run"
21
- },
22
- "dependencies": {
23
- "json5": "2.2.3"
24
- },
25
- "devDependencies": {
26
- "@types/node": "^20",
27
- "typescript": "^5",
28
- "vitest": "^4.0.18"
29
- },
30
- "license": "MIT",
31
- "homepage": "https://reefclaw.com"
32
- }
1
+ {
2
+ "name": "@reefclaw/connect",
3
+ "version": "0.1.28",
4
+ "description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
5
+ "type": "module",
6
+ "bin": {
7
+ "reefclaw-connect": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "assets"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "bundle-assets": "node scripts/bundle-assets.mjs",
18
+ "build": "tsc && node scripts/bundle-assets.mjs",
19
+ "test": "vitest",
20
+ "test:run": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "json5": "2.2.3"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20",
27
+ "typescript": "^5",
28
+ "vitest": "^4.0.18"
29
+ },
30
+ "license": "MIT",
31
+ "homepage": "https://reefclaw.com"
32
+ }
package/dist/daemon.js DELETED
@@ -1,104 +0,0 @@
1
- // Keep the bridge running across reboots. Linux/systemd-user is implemented
2
- // fully; macOS and Windows fall back to printing the manual run command (a
3
- // launchd/Task-Scheduler unit is a follow-up). The bridge reads its config from
4
- // ~/.openclaw/openclaw.json, so until the user pastes their connect message the
5
- // service will start, find no token, and restart — harmless; it connects within
6
- // seconds of the agent writing the config.
7
- import { writeFileSync, mkdirSync } from 'node:fs';
8
- import { join } from 'node:path';
9
- import { homedir, userInfo } from 'node:os';
10
- import { BRIDGE_DIR } from './paths.js';
11
- import { run, which } from './exec.js';
12
- import { step, ok, info, warn } from './ui.js';
13
- const SERVICE_NAME = 'reefclaw-bridge';
14
- const NODE = process.execPath; // absolute path to the node running the installer
15
- /**
16
- * Build the systemd user-unit text. Pure + exported so the path-quoting is
17
- * unit-testable. Both `node` (process.execPath) and `bridgeDir` (under the
18
- * user's home) can contain spaces. Quoting rules differ per directive:
19
- * - ExecStart= is parsed with shell-like word splitting, so an unquoted
20
- * `ExecStart=/home/a b/node …` reads the binary as `/home/a` — QUOTE both
21
- * the binary and the script path.
22
- * - WorkingDirectory= takes the raw value after `=` as a single path (no word
23
- * splitting) — spaces are safe UNQUOTED, and quotes are treated as literal
24
- * characters, failing the unit with "path is not absolute" (verified live
25
- * on systemd 255 / Ubuntu 24.04). Do NOT quote it.
26
- */
27
- export function buildSystemdUnit(node, bridgeDir) {
28
- const indexJs = join(bridgeDir, 'index.js');
29
- return `[Unit]
30
- Description=ReefClaw connector - bridges OpenClaw to the ReefClaw dashboard
31
- After=network-online.target
32
- Wants=network-online.target
33
-
34
- [Service]
35
- Type=simple
36
- WorkingDirectory=${bridgeDir}
37
- ExecStart="${node}" "${indexJs}" --provider gateway --log-level info
38
- Restart=always
39
- RestartSec=5s
40
-
41
- [Install]
42
- WantedBy=default.target
43
- `;
44
- }
45
- function manualHint() {
46
- warn('Could not set up an auto-start service on this OS yet.');
47
- info('Keep the connector running with this command (leave it open / use your own service manager):');
48
- info(` "${NODE}" "${join(BRIDGE_DIR, 'index.js')}" --provider gateway`);
49
- }
50
- function installSystemd() {
51
- if (!which('systemctl'))
52
- return false;
53
- const unitDir = join(homedir(), '.config', 'systemd', 'user');
54
- mkdirSync(unitDir, { recursive: true });
55
- const unit = buildSystemdUnit(NODE, BRIDGE_DIR);
56
- writeFileSync(join(unitDir, `${SERVICE_NAME}.service`), unit, 'utf-8');
57
- run('systemctl', ['--user', 'daemon-reload']);
58
- const enabled = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
59
- if (!enabled.ok) {
60
- warn('systemd --user enable/start did not succeed:');
61
- if (enabled.stderr.trim())
62
- info(enabled.stderr.trim().split('\n').slice(-2).join('\n'));
63
- info(`Try: systemctl --user enable --now ${SERVICE_NAME}.service`);
64
- return false;
65
- }
66
- // `enable --now` can exit 0 while the unit failed to load (e.g. a bad unit
67
- // file setting) — verify the unit actually came up before claiming ✓.
68
- // 'active' = running; 'activating' = the expected pre-token restart loop
69
- // (the bridge exits until the user pastes their connect message, and
70
- // Restart=always re-launches it). Anything else (inactive/failed) means the
71
- // unit never loaded.
72
- const active = run('systemctl', ['--user', 'is-active', `${SERVICE_NAME}.service`]);
73
- const state = active.stdout.trim();
74
- if (state !== 'active' && state !== 'activating') {
75
- warn(`the service did not come up (state: ${state || 'unknown'}).`);
76
- info(`Inspect: systemctl --user status ${SERVICE_NAME}.service`);
77
- return false;
78
- }
79
- // Linger lets the user service run without an active login session (servers).
80
- // Best-effort: needs privileges; non-fatal if it fails.
81
- const linger = run('loginctl', ['enable-linger', userInfo().username]);
82
- if (linger.ok) {
83
- info('enabled linger (service survives logout / reboot)');
84
- }
85
- else {
86
- info('note: run `sudo loginctl enable-linger $USER` so the connector survives logout.');
87
- }
88
- return true;
89
- }
90
- export function installDaemon() {
91
- step('Starting the connector as a background service');
92
- if (process.platform === 'linux') {
93
- if (installSystemd()) {
94
- ok(`connector running as a systemd user service (${SERVICE_NAME})`);
95
- info(`logs: journalctl --user -u ${SERVICE_NAME} -f`);
96
- return true;
97
- }
98
- manualHint();
99
- return false;
100
- }
101
- // macOS / Windows: manual for now (launchd / Task Scheduler unit is a follow-up).
102
- manualHint();
103
- return false;
104
- }