@clawlabz/clawnetwork 0.1.0
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/README.md +143 -0
- package/index.ts +1545 -0
- package/openclaw.plugin.json +63 -0
- package/package.json +55 -0
- package/skills/clawnetwork.md +43 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
2
|
+
declare const process: { stdout: { write: (s: string) => void }; env: Record<string, string | undefined>; platform: string; arch: string; kill: (pid: number, sig: string) => boolean; pid: number; on: (event: string, handler: (...args: unknown[]) => void) => void }
|
|
3
|
+
declare function require(id: string): any
|
|
4
|
+
declare function setTimeout(fn: () => void, ms: number): unknown
|
|
5
|
+
declare function clearTimeout(id: unknown): void
|
|
6
|
+
declare function setInterval(fn: () => void, ms: number): unknown
|
|
7
|
+
declare function clearInterval(id: unknown): void
|
|
8
|
+
declare function fetch(url: string, init?: Record<string, unknown>): Promise<{ status: number; ok: boolean; text: () => Promise<string>; json: () => Promise<unknown> }>
|
|
9
|
+
|
|
10
|
+
const VERSION = '0.1.0'
|
|
11
|
+
const PLUGIN_ID = 'clawnetwork'
|
|
12
|
+
const GITHUB_REPO = 'clawlabz/claw-network'
|
|
13
|
+
const DEFAULT_RPC_PORT = 9710
|
|
14
|
+
const DEFAULT_P2P_PORT = 9711
|
|
15
|
+
const DEFAULT_NETWORK = 'mainnet'
|
|
16
|
+
const DEFAULT_SYNC_MODE = 'full'
|
|
17
|
+
const DEFAULT_HEALTH_CHECK_SECONDS = 30
|
|
18
|
+
const DEFAULT_UI_PORT = 19877
|
|
19
|
+
const MAX_RESTART_ATTEMPTS = 3
|
|
20
|
+
const RESTART_BACKOFF_BASE_MS = 5_000
|
|
21
|
+
const DECIMALS = 9
|
|
22
|
+
const ONE_CLAW = BigInt(10 ** DECIMALS)
|
|
23
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024 // 5 MB log rotation threshold
|
|
24
|
+
const HEX64_RE = /^[0-9a-f]{64}$/i
|
|
25
|
+
const HEX_RE = /^[0-9a-f]+$/i
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// OpenClaw API Types (mirrors Gateway runtime)
|
|
29
|
+
// ============================================================
|
|
30
|
+
|
|
31
|
+
type GatewayRespond = (ok: boolean, payload: Record<string, unknown>) => void
|
|
32
|
+
|
|
33
|
+
interface GatewayMethodContext {
|
|
34
|
+
respond?: GatewayRespond
|
|
35
|
+
params?: Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CliCommandChain {
|
|
39
|
+
description: (text: string) => CliCommandChain
|
|
40
|
+
argument: (spec: string, desc?: string) => CliCommandChain
|
|
41
|
+
option: (flags: string, desc: string) => CliCommandChain
|
|
42
|
+
action: (handler: (...args: unknown[]) => void) => CliCommandChain
|
|
43
|
+
command: (name: string) => CliCommandChain
|
|
44
|
+
allowExcessArguments: (allow: boolean) => CliCommandChain
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CliProgram {
|
|
48
|
+
command: (name: string) => CliCommandChain
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RegisterCliContext {
|
|
52
|
+
program: CliProgram
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface OpenClawApi {
|
|
56
|
+
config?: Record<string, unknown>
|
|
57
|
+
logger?: {
|
|
58
|
+
info?: (message: string, payload?: Record<string, unknown>) => void
|
|
59
|
+
warn?: (message: string, payload?: Record<string, unknown>) => void
|
|
60
|
+
error?: (message: string, payload?: Record<string, unknown>) => void
|
|
61
|
+
}
|
|
62
|
+
registerGatewayMethod?: (name: string, handler: (ctx: GatewayMethodContext) => void) => void
|
|
63
|
+
registerCli?: (
|
|
64
|
+
handler: (ctx: RegisterCliContext) => void,
|
|
65
|
+
options?: { commands?: string[] }
|
|
66
|
+
) => void
|
|
67
|
+
registerService?: (service: { id: string; start?: () => void; stop?: () => void }) => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================
|
|
71
|
+
// Configuration
|
|
72
|
+
// ============================================================
|
|
73
|
+
|
|
74
|
+
interface PluginConfig {
|
|
75
|
+
network: string
|
|
76
|
+
autoStart: boolean
|
|
77
|
+
autoDownload: boolean
|
|
78
|
+
autoRegisterAgent: boolean
|
|
79
|
+
rpcPort: number
|
|
80
|
+
p2pPort: number
|
|
81
|
+
syncMode: string
|
|
82
|
+
healthCheckSeconds: number
|
|
83
|
+
uiPort: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getConfig(api: OpenClawApi): PluginConfig {
|
|
87
|
+
const c = (api.config && typeof api.config === 'object') ? api.config : {}
|
|
88
|
+
return {
|
|
89
|
+
network: typeof c.network === 'string' ? c.network : DEFAULT_NETWORK,
|
|
90
|
+
autoStart: typeof c.autoStart === 'boolean' ? c.autoStart : true,
|
|
91
|
+
autoDownload: typeof c.autoDownload === 'boolean' ? c.autoDownload : true,
|
|
92
|
+
autoRegisterAgent: typeof c.autoRegisterAgent === 'boolean' ? c.autoRegisterAgent : true,
|
|
93
|
+
rpcPort: typeof c.rpcPort === 'number' ? c.rpcPort : DEFAULT_RPC_PORT,
|
|
94
|
+
p2pPort: typeof c.p2pPort === 'number' ? c.p2pPort : DEFAULT_P2P_PORT,
|
|
95
|
+
syncMode: typeof c.syncMode === 'string' ? c.syncMode : DEFAULT_SYNC_MODE,
|
|
96
|
+
healthCheckSeconds: typeof c.healthCheckSeconds === 'number' ? c.healthCheckSeconds : DEFAULT_HEALTH_CHECK_SECONDS,
|
|
97
|
+
uiPort: typeof c.uiPort === 'number' ? c.uiPort : DEFAULT_UI_PORT,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================
|
|
102
|
+
// Utilities
|
|
103
|
+
// ============================================================
|
|
104
|
+
|
|
105
|
+
const os = require('os')
|
|
106
|
+
const path = require('path')
|
|
107
|
+
const fs = require('fs')
|
|
108
|
+
const { execFileSync, spawn: nodeSpawn, fork } = require('child_process')
|
|
109
|
+
|
|
110
|
+
function homePath(...segments: string[]): string {
|
|
111
|
+
return path.join(os.homedir(), ...segments)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const WORKSPACE_DIR = homePath('.openclaw', 'workspace', 'clawnetwork')
|
|
115
|
+
const BIN_DIR = homePath('.openclaw', 'bin')
|
|
116
|
+
const DATA_DIR = homePath('.clawnetwork')
|
|
117
|
+
const WALLET_PATH = path.join(WORKSPACE_DIR, 'wallet.json')
|
|
118
|
+
const LOG_PATH = path.join(WORKSPACE_DIR, 'node.log')
|
|
119
|
+
const UI_PORT_FILE = homePath('.openclaw', 'clawnetwork-ui-port')
|
|
120
|
+
|
|
121
|
+
function ensureDir(dir: string): void {
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sleep(ms: number): Promise<void> {
|
|
126
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatClaw(raw: bigint | string): string {
|
|
130
|
+
const value = typeof raw === 'string' ? BigInt(raw) : raw
|
|
131
|
+
const whole = value / ONE_CLAW
|
|
132
|
+
const frac = value % ONE_CLAW
|
|
133
|
+
if (frac === 0n) return `${whole} CLAW`
|
|
134
|
+
const fracStr = frac.toString().padStart(DECIMALS, '0').replace(/0+$/, '')
|
|
135
|
+
return `${whole}.${fracStr} CLAW`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatUptime(seconds: number): string {
|
|
139
|
+
if (seconds < 60) return `${seconds}s`
|
|
140
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
|
|
141
|
+
const h = Math.floor(seconds / 3600)
|
|
142
|
+
const m = Math.floor((seconds % 3600) / 60)
|
|
143
|
+
return `${h}h ${m}m`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Input validation ──
|
|
147
|
+
|
|
148
|
+
function isValidAddress(addr: string): boolean {
|
|
149
|
+
return HEX64_RE.test(addr)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isValidPrivateKey(key: string): boolean {
|
|
153
|
+
return key.length === 64 && HEX_RE.test(key)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isValidAmount(amount: string): boolean {
|
|
157
|
+
return /^\d+(\.\d+)?$/.test(amount) && parseFloat(amount) > 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isValidNetwork(network: string): boolean {
|
|
161
|
+
return ['mainnet', 'testnet', 'devnet'].includes(network)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isValidSyncMode(mode: string): boolean {
|
|
165
|
+
return ['full', 'fast', 'light'].includes(mode)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sanitizeAgentName(name: string): string {
|
|
169
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Log rotation ──
|
|
173
|
+
|
|
174
|
+
function rotateLogIfNeeded(): void {
|
|
175
|
+
try {
|
|
176
|
+
if (!fs.existsSync(LOG_PATH)) return
|
|
177
|
+
const stat = fs.statSync(LOG_PATH)
|
|
178
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
179
|
+
const rotated = `${LOG_PATH}.1`
|
|
180
|
+
try { fs.unlinkSync(rotated) } catch { /* ok */ }
|
|
181
|
+
fs.renameSync(LOG_PATH, rotated)
|
|
182
|
+
}
|
|
183
|
+
} catch { /* ok */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================
|
|
187
|
+
// Binary Management
|
|
188
|
+
// ============================================================
|
|
189
|
+
|
|
190
|
+
function findBinary(): string | null {
|
|
191
|
+
// 1. Check our managed location
|
|
192
|
+
const managedPath = path.join(BIN_DIR, process.platform === 'win32' ? 'claw-node.exe' : 'claw-node')
|
|
193
|
+
if (fs.existsSync(managedPath)) return managedPath
|
|
194
|
+
|
|
195
|
+
// 2. Check if in PATH
|
|
196
|
+
try {
|
|
197
|
+
const which = process.platform === 'win32' ? 'where' : 'which'
|
|
198
|
+
const result = execFileSync(which, ['claw-node'], { encoding: 'utf8', timeout: 5000 }).trim()
|
|
199
|
+
if (result) return result.split('\n')[0]
|
|
200
|
+
} catch { /* not found */ }
|
|
201
|
+
|
|
202
|
+
// 3. Check data dir bin (install.sh puts it here)
|
|
203
|
+
const dataDirBin = path.join(DATA_DIR, 'bin', 'claw-node')
|
|
204
|
+
if (fs.existsSync(dataDirBin)) return dataDirBin
|
|
205
|
+
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getBinaryVersion(binaryPath: string): string | null {
|
|
210
|
+
try {
|
|
211
|
+
const output = execFileSync(binaryPath, ['--version'], { encoding: 'utf8', timeout: 5000 }).trim()
|
|
212
|
+
const match = output.match(/(\d+\.\d+\.\d+)/)
|
|
213
|
+
return match ? match[1] : output
|
|
214
|
+
} catch { return null }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function detectPlatformTarget(): string {
|
|
218
|
+
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux'
|
|
219
|
+
const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
|
|
220
|
+
return `${platform}-${arch}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function downloadBinary(api: OpenClawApi): Promise<string> {
|
|
224
|
+
ensureDir(BIN_DIR)
|
|
225
|
+
const target = detectPlatformTarget()
|
|
226
|
+
const ext = process.platform === 'win32' ? 'zip' : 'tar.gz'
|
|
227
|
+
const binaryName = process.platform === 'win32' ? 'claw-node.exe' : 'claw-node'
|
|
228
|
+
const destPath = path.join(BIN_DIR, binaryName)
|
|
229
|
+
|
|
230
|
+
api.logger?.info?.(`[clawnetwork] downloading claw-node for ${target}...`)
|
|
231
|
+
|
|
232
|
+
// Resolve latest version from GitHub (HTTPS only)
|
|
233
|
+
let version = 'latest'
|
|
234
|
+
try {
|
|
235
|
+
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`)
|
|
236
|
+
if (res.ok) {
|
|
237
|
+
const data = await res.json() as Record<string, unknown>
|
|
238
|
+
if (typeof data.tag_name === 'string') version = data.tag_name
|
|
239
|
+
}
|
|
240
|
+
} catch { /* fallback to latest redirect */ }
|
|
241
|
+
|
|
242
|
+
const baseUrl = version === 'latest'
|
|
243
|
+
? `https://github.com/${GITHUB_REPO}/releases/latest/download`
|
|
244
|
+
: `https://github.com/${GITHUB_REPO}/releases/download/${version}`
|
|
245
|
+
|
|
246
|
+
const downloadUrl = `${baseUrl}/claw-node-${target}.${ext}`
|
|
247
|
+
const checksumUrl = `${baseUrl}/SHA256SUMS.txt`
|
|
248
|
+
api.logger?.info?.(`[clawnetwork] download URL: ${downloadUrl}`)
|
|
249
|
+
|
|
250
|
+
const tmpFile = path.join(os.tmpdir(), `claw-node-download-${Date.now()}.${ext}`)
|
|
251
|
+
try {
|
|
252
|
+
execFileSync('curl', ['-sSfL', '-o', tmpFile, downloadUrl], { timeout: 120_000 })
|
|
253
|
+
} catch {
|
|
254
|
+
try {
|
|
255
|
+
execFileSync('wget', ['-qO', tmpFile, downloadUrl], { timeout: 120_000 })
|
|
256
|
+
} catch (e: unknown) {
|
|
257
|
+
throw new Error(`Failed to download claw-node: ${(e as Error).message}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Verify SHA256 checksum
|
|
262
|
+
try {
|
|
263
|
+
const checksumTmp = path.join(os.tmpdir(), `claw-node-sha256-${Date.now()}.txt`)
|
|
264
|
+
execFileSync('curl', ['-sSfL', '-o', checksumTmp, checksumUrl], { timeout: 30_000 })
|
|
265
|
+
const checksumContent = fs.readFileSync(checksumTmp, 'utf8')
|
|
266
|
+
const expectedLine = checksumContent.split('\n').find((l: string) => l.includes(`claw-node-${target}`))
|
|
267
|
+
if (expectedLine) {
|
|
268
|
+
const expectedHash = expectedLine.split(/\s+/)[0]
|
|
269
|
+
const cmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum'
|
|
270
|
+
const args = process.platform === 'darwin' ? ['-a', '256', tmpFile] : [tmpFile]
|
|
271
|
+
const actualOutput = execFileSync(cmd, args, { encoding: 'utf8', timeout: 30_000 })
|
|
272
|
+
const actualHash = actualOutput.split(/\s+/)[0]
|
|
273
|
+
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
274
|
+
fs.unlinkSync(tmpFile)
|
|
275
|
+
try { fs.unlinkSync(checksumTmp) } catch { /* ok */ }
|
|
276
|
+
throw new Error(`SHA256 mismatch: expected ${expectedHash}, got ${actualHash}`)
|
|
277
|
+
}
|
|
278
|
+
api.logger?.info?.(`[clawnetwork] SHA256 verified: ${actualHash.slice(0, 16)}...`)
|
|
279
|
+
}
|
|
280
|
+
try { fs.unlinkSync(checksumTmp) } catch { /* ok */ }
|
|
281
|
+
} catch (e: unknown) {
|
|
282
|
+
// If checksum verification fails but file was downloaded, warn but continue
|
|
283
|
+
const msg = (e as Error).message
|
|
284
|
+
if (msg.includes('SHA256 mismatch')) throw e
|
|
285
|
+
api.logger?.warn?.(`[clawnetwork] checksum verification skipped: ${msg}`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Extract
|
|
289
|
+
if (ext === 'tar.gz') {
|
|
290
|
+
execFileSync('tar', ['xzf', tmpFile, '-C', BIN_DIR], { timeout: 30_000 })
|
|
291
|
+
} else {
|
|
292
|
+
execFileSync('powershell', ['-Command', `Expand-Archive -Path "${tmpFile}" -DestinationPath "${BIN_DIR}" -Force`], { timeout: 30_000 })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Ensure executable
|
|
296
|
+
if (process.platform !== 'win32') {
|
|
297
|
+
fs.chmodSync(destPath, 0o755)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Cleanup
|
|
301
|
+
try { fs.unlinkSync(tmpFile) } catch { /* ok */ }
|
|
302
|
+
|
|
303
|
+
if (!fs.existsSync(destPath)) {
|
|
304
|
+
throw new Error(`Binary not found after extraction at ${destPath}`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
api.logger?.info?.(`[clawnetwork] claw-node installed at ${destPath} (${version})`)
|
|
308
|
+
return destPath
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================
|
|
312
|
+
// Node Init
|
|
313
|
+
// ============================================================
|
|
314
|
+
|
|
315
|
+
function isInitialized(): boolean {
|
|
316
|
+
// Both genesis config AND chain data must exist for a proper init
|
|
317
|
+
const hasGenesis = fs.existsSync(path.join(DATA_DIR, 'genesis.json'))
|
|
318
|
+
const hasChainDb = fs.existsSync(path.join(DATA_DIR, 'chain.redb'))
|
|
319
|
+
return hasGenesis && hasChainDb
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function initNode(binaryPath: string, network: string, api: OpenClawApi): void {
|
|
323
|
+
if (!isValidNetwork(network)) throw new Error(`Invalid network: ${network}`)
|
|
324
|
+
if (isInitialized()) {
|
|
325
|
+
api.logger?.info?.('[clawnetwork] node already initialized, skipping init')
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
api.logger?.info?.(`[clawnetwork] initializing node for ${network}...`)
|
|
329
|
+
try {
|
|
330
|
+
const output = execFileSync(binaryPath, ['init', '--network', network], {
|
|
331
|
+
encoding: 'utf8',
|
|
332
|
+
timeout: 30_000,
|
|
333
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' }, // minimal env
|
|
334
|
+
})
|
|
335
|
+
api.logger?.info?.(`[clawnetwork] init complete: ${output.trim().slice(0, 200)}`)
|
|
336
|
+
} catch (e: unknown) {
|
|
337
|
+
throw new Error(`Node init failed: ${(e as Error).message}`)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// Wallet Management
|
|
343
|
+
// ============================================================
|
|
344
|
+
|
|
345
|
+
interface WalletData {
|
|
346
|
+
address: string
|
|
347
|
+
secretKey: string
|
|
348
|
+
createdAt: string
|
|
349
|
+
network: string
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function loadWallet(): WalletData | null {
|
|
353
|
+
try {
|
|
354
|
+
const raw = fs.readFileSync(WALLET_PATH, 'utf8')
|
|
355
|
+
return JSON.parse(raw) as WalletData
|
|
356
|
+
} catch { return null }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function saveWallet(data: WalletData): void {
|
|
360
|
+
ensureDir(WORKSPACE_DIR)
|
|
361
|
+
fs.writeFileSync(WALLET_PATH, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function ensureWallet(network: string, api?: OpenClawApi): WalletData {
|
|
365
|
+
const existing = loadWallet()
|
|
366
|
+
if (existing) return existing
|
|
367
|
+
|
|
368
|
+
// Try to read from claw-node's key.json
|
|
369
|
+
const nodeKeyPath = path.join(DATA_DIR, 'key.json')
|
|
370
|
+
if (fs.existsSync(nodeKeyPath)) {
|
|
371
|
+
try {
|
|
372
|
+
const nodeKey = JSON.parse(fs.readFileSync(nodeKeyPath, 'utf8'))
|
|
373
|
+
const wallet: WalletData = {
|
|
374
|
+
address: String(nodeKey.address || nodeKey.public_key || ''),
|
|
375
|
+
secretKey: String(nodeKey.secret_key || nodeKey.private_key || ''),
|
|
376
|
+
createdAt: new Date().toISOString(),
|
|
377
|
+
network,
|
|
378
|
+
}
|
|
379
|
+
if (wallet.address && wallet.secretKey) {
|
|
380
|
+
saveWallet(wallet)
|
|
381
|
+
api?.logger?.info?.(`[clawnetwork] wallet loaded from node key: ${wallet.address.slice(0, 12)}...`)
|
|
382
|
+
return wallet
|
|
383
|
+
}
|
|
384
|
+
} catch { /* fallthrough to generate */ }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Generate new wallet via crypto.randomBytes
|
|
388
|
+
const crypto = require('crypto')
|
|
389
|
+
const privKey = crypto.randomBytes(32)
|
|
390
|
+
const secretKeyHex = privKey.toString('hex')
|
|
391
|
+
|
|
392
|
+
let address = ''
|
|
393
|
+
const binary = findBinary()
|
|
394
|
+
if (binary) {
|
|
395
|
+
try {
|
|
396
|
+
execFileSync(binary, ['key', 'import', secretKeyHex], {
|
|
397
|
+
encoding: 'utf8',
|
|
398
|
+
timeout: 10_000,
|
|
399
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
400
|
+
})
|
|
401
|
+
const showOut = execFileSync(binary, ['key', 'show'], {
|
|
402
|
+
encoding: 'utf8',
|
|
403
|
+
timeout: 5000,
|
|
404
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
405
|
+
}).trim()
|
|
406
|
+
const showMatch = showOut.match(/[0-9a-f]{64}/i)
|
|
407
|
+
if (showMatch) address = showMatch[0]
|
|
408
|
+
} catch { /* ok, address resolved later */ }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const wallet: WalletData = {
|
|
412
|
+
address,
|
|
413
|
+
secretKey: secretKeyHex,
|
|
414
|
+
createdAt: new Date().toISOString(),
|
|
415
|
+
network,
|
|
416
|
+
}
|
|
417
|
+
saveWallet(wallet)
|
|
418
|
+
api?.logger?.info?.(`[clawnetwork] new wallet generated: ${address ? address.slice(0, 12) + '...' : '(pending)'}`)
|
|
419
|
+
return wallet
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ============================================================
|
|
423
|
+
// Node Process Manager
|
|
424
|
+
// ============================================================
|
|
425
|
+
|
|
426
|
+
let nodeProcess: any = null
|
|
427
|
+
let healthTimer: unknown = null
|
|
428
|
+
let restartCount = 0
|
|
429
|
+
let stopping = false
|
|
430
|
+
|
|
431
|
+
interface NodeStatus {
|
|
432
|
+
running: boolean
|
|
433
|
+
pid: number | null
|
|
434
|
+
blockHeight: number | null
|
|
435
|
+
peerCount: number | null
|
|
436
|
+
network: string
|
|
437
|
+
syncMode: string
|
|
438
|
+
rpcUrl: string
|
|
439
|
+
walletAddress: string
|
|
440
|
+
binaryVersion: string | null
|
|
441
|
+
pluginVersion: string
|
|
442
|
+
uptime: number | null
|
|
443
|
+
uptimeFormatted: string | null
|
|
444
|
+
restartCount: number
|
|
445
|
+
dataDir: string
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let nodeStartedAt: number | null = null
|
|
449
|
+
let lastHealth: { blockHeight: number | null; peerCount: number | null; syncing: boolean } = { blockHeight: null, peerCount: null, syncing: false }
|
|
450
|
+
let cachedBinaryVersion: string | null = null
|
|
451
|
+
|
|
452
|
+
function isNodeRunning(): { running: boolean; pid: number | null } {
|
|
453
|
+
// Check in-memory process first
|
|
454
|
+
if (nodeProcess && !nodeProcess.killed) return { running: true, pid: nodeProcess.pid }
|
|
455
|
+
// Check PID file (for detached processes from previous CLI invocations)
|
|
456
|
+
const pidFile = path.join(WORKSPACE_DIR, 'node.pid')
|
|
457
|
+
try {
|
|
458
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10)
|
|
459
|
+
if (pid > 0) {
|
|
460
|
+
try { execFileSync('kill', ['-0', String(pid)], { timeout: 2000 }); return { running: true, pid } } catch { /* dead */ }
|
|
461
|
+
}
|
|
462
|
+
} catch { /* no file */ }
|
|
463
|
+
// Last resort: check if RPC port is responding (covers orphaned processes)
|
|
464
|
+
try {
|
|
465
|
+
execFileSync('curl', ['-sf', '--max-time', '1', 'http://localhost:9710/health'], { timeout: 3000, encoding: 'utf8' })
|
|
466
|
+
// Port is responding — find PID by port
|
|
467
|
+
try {
|
|
468
|
+
const lsof = execFileSync('lsof', ['-ti', ':9710'], { timeout: 3000, encoding: 'utf8' }).trim()
|
|
469
|
+
const pid = parseInt(lsof.split('\n')[0], 10)
|
|
470
|
+
if (pid > 0) return { running: true, pid }
|
|
471
|
+
} catch { /* ok */ }
|
|
472
|
+
return { running: true, pid: null }
|
|
473
|
+
} catch { /* not responding */ }
|
|
474
|
+
return { running: false, pid: null }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function buildStatus(cfg: PluginConfig): NodeStatus {
|
|
478
|
+
const wallet = loadWallet()
|
|
479
|
+
const nodeState = isNodeRunning()
|
|
480
|
+
const uptime = nodeStartedAt ? Math.floor((Date.now() - nodeStartedAt) / 1000) : null
|
|
481
|
+
return {
|
|
482
|
+
running: nodeState.running,
|
|
483
|
+
pid: nodeState.pid,
|
|
484
|
+
blockHeight: lastHealth.blockHeight,
|
|
485
|
+
peerCount: lastHealth.peerCount,
|
|
486
|
+
network: cfg.network,
|
|
487
|
+
syncMode: cfg.syncMode,
|
|
488
|
+
rpcUrl: `http://localhost:${cfg.rpcPort}`,
|
|
489
|
+
walletAddress: wallet?.address ?? '',
|
|
490
|
+
binaryVersion: cachedBinaryVersion,
|
|
491
|
+
pluginVersion: VERSION,
|
|
492
|
+
uptime,
|
|
493
|
+
uptimeFormatted: uptime !== null ? formatUptime(uptime) : null,
|
|
494
|
+
restartCount,
|
|
495
|
+
dataDir: DATA_DIR,
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function checkHealth(rpcPort: number): Promise<{ blockHeight: number | null; peerCount: number | null; syncing: boolean }> {
|
|
500
|
+
try {
|
|
501
|
+
const res = await fetch(`http://localhost:${rpcPort}/health`)
|
|
502
|
+
if (!res.ok) return { blockHeight: null, peerCount: null, syncing: false }
|
|
503
|
+
const data = await res.json() as Record<string, unknown>
|
|
504
|
+
return {
|
|
505
|
+
blockHeight: typeof data.block_height === 'number' ? data.block_height : typeof data.blockHeight === 'number' ? data.blockHeight : null,
|
|
506
|
+
peerCount: typeof data.peer_count === 'number' ? data.peer_count : typeof data.peers === 'number' ? data.peers : null,
|
|
507
|
+
syncing: data.syncing === true,
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
return { blockHeight: null, peerCount: null, syncing: false }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function rpcCall(rpcPort: number, method: string, params: unknown[] = []): Promise<unknown> {
|
|
515
|
+
const res = await fetch(`http://localhost:${rpcPort}`, {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: { 'Content-Type': 'application/json' },
|
|
518
|
+
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: Date.now() }),
|
|
519
|
+
})
|
|
520
|
+
const data = await res.json() as Record<string, unknown>
|
|
521
|
+
if (data.error) {
|
|
522
|
+
const err = data.error as Record<string, unknown>
|
|
523
|
+
throw new Error(String(err.message || JSON.stringify(err)))
|
|
524
|
+
}
|
|
525
|
+
return data.result
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function startNodeProcess(binaryPath: string, cfg: PluginConfig, api: OpenClawApi): void {
|
|
529
|
+
// Guard: check in-memory reference, PID file, AND health endpoint
|
|
530
|
+
if (nodeProcess && !nodeProcess.killed) {
|
|
531
|
+
api.logger?.warn?.('[clawnetwork] node already running (in-memory)')
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
const existingState = isNodeRunning()
|
|
535
|
+
if (existingState.running) {
|
|
536
|
+
api.logger?.info?.(`[clawnetwork] node already running (pid=${existingState.pid}), skipping start`)
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!isValidNetwork(cfg.network)) { api.logger?.error?.(`[clawnetwork] invalid network: ${cfg.network}`); return }
|
|
541
|
+
if (!isValidSyncMode(cfg.syncMode)) { api.logger?.error?.(`[clawnetwork] invalid sync mode: ${cfg.syncMode}`); return }
|
|
542
|
+
|
|
543
|
+
const args = ['start', '--network', cfg.network, '--rpc-port', String(cfg.rpcPort), '--p2p-port', String(cfg.p2pPort), '--sync-mode', cfg.syncMode, '--allow-genesis']
|
|
544
|
+
|
|
545
|
+
api.logger?.info?.(`[clawnetwork] starting node: ${binaryPath} ${args.join(' ')}`)
|
|
546
|
+
|
|
547
|
+
rotateLogIfNeeded()
|
|
548
|
+
ensureDir(WORKSPACE_DIR)
|
|
549
|
+
|
|
550
|
+
// Minimal env to prevent leaking secrets from parent process
|
|
551
|
+
const safeEnv: Record<string, string> = {
|
|
552
|
+
HOME: os.homedir(),
|
|
553
|
+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
554
|
+
RUST_LOG: process.env.RUST_LOG || 'claw=info',
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Open log file as fd for direct stdio redirect (allows parent process to exit)
|
|
558
|
+
const logFd = fs.openSync(LOG_PATH, 'a')
|
|
559
|
+
|
|
560
|
+
nodeProcess = nodeSpawn(binaryPath, args, {
|
|
561
|
+
stdio: ['ignore', logFd, logFd],
|
|
562
|
+
detached: true,
|
|
563
|
+
env: safeEnv,
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
// Unref so CLI process can exit while node keeps running in background
|
|
567
|
+
nodeProcess.unref()
|
|
568
|
+
|
|
569
|
+
nodeStartedAt = Date.now()
|
|
570
|
+
restartCount = 0
|
|
571
|
+
stopping = false
|
|
572
|
+
|
|
573
|
+
// Cache binary version
|
|
574
|
+
cachedBinaryVersion = getBinaryVersion(binaryPath)
|
|
575
|
+
|
|
576
|
+
// Save PID for later management
|
|
577
|
+
const pidFile = path.join(WORKSPACE_DIR, 'node.pid')
|
|
578
|
+
fs.writeFileSync(pidFile, String(nodeProcess.pid))
|
|
579
|
+
|
|
580
|
+
nodeProcess.on('exit', (code: number | null) => {
|
|
581
|
+
api.logger?.warn?.(`[clawnetwork] node exited with code ${code}`)
|
|
582
|
+
fs.closeSync(logFd)
|
|
583
|
+
nodeProcess = null
|
|
584
|
+
nodeStartedAt = null
|
|
585
|
+
lastHealth = { blockHeight: null, peerCount: null, syncing: false }
|
|
586
|
+
try { fs.unlinkSync(pidFile) } catch { /* ok */ }
|
|
587
|
+
|
|
588
|
+
// Check file-based stop signal (set by stop from different CLI process)
|
|
589
|
+
const stopFile = path.join(WORKSPACE_DIR, 'stop.signal')
|
|
590
|
+
const wasStopped = stopping || fs.existsSync(stopFile)
|
|
591
|
+
try { fs.unlinkSync(stopFile) } catch { /* ok */ }
|
|
592
|
+
|
|
593
|
+
if (!wasStopped && code !== 0 && restartCount < MAX_RESTART_ATTEMPTS) {
|
|
594
|
+
restartCount++
|
|
595
|
+
const delay = RESTART_BACKOFF_BASE_MS * Math.pow(2, restartCount - 1)
|
|
596
|
+
api.logger?.info?.(`[clawnetwork] restarting in ${delay}ms (attempt ${restartCount}/${MAX_RESTART_ATTEMPTS})...`)
|
|
597
|
+
setTimeout(() => startNodeProcess(binaryPath, cfg, api), delay)
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
startHealthCheck(cfg, api)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function startHealthCheck(cfg: PluginConfig, api: OpenClawApi): void {
|
|
605
|
+
if (healthTimer) clearTimeout(healthTimer)
|
|
606
|
+
|
|
607
|
+
const check = async () => {
|
|
608
|
+
lastHealth = await checkHealth(cfg.rpcPort)
|
|
609
|
+
if (lastHealth.blockHeight !== null) {
|
|
610
|
+
api.logger?.info?.(`[clawnetwork] height=${lastHealth.blockHeight} peers=${lastHealth.peerCount} syncing=${lastHealth.syncing}`)
|
|
611
|
+
}
|
|
612
|
+
if (!stopping) {
|
|
613
|
+
healthTimer = setTimeout(check, cfg.healthCheckSeconds * 1000)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
healthTimer = setTimeout(check, 5000)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function stopNode(api: OpenClawApi): void {
|
|
621
|
+
stopping = true
|
|
622
|
+
if (healthTimer) {
|
|
623
|
+
clearTimeout(healthTimer)
|
|
624
|
+
healthTimer = null
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Find PID: in-memory process or PID file (for detached processes)
|
|
628
|
+
let pid: number | null = nodeProcess?.pid ?? null
|
|
629
|
+
const pidFile = path.join(WORKSPACE_DIR, 'node.pid')
|
|
630
|
+
if (!pid) {
|
|
631
|
+
try {
|
|
632
|
+
const savedPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10)
|
|
633
|
+
if (savedPid > 0) pid = savedPid
|
|
634
|
+
} catch { /* no pid file */ }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (pid) {
|
|
638
|
+
api.logger?.info?.(`[clawnetwork] stopping node pid=${pid} (SIGTERM)...`)
|
|
639
|
+
try { process.kill(pid, 'SIGTERM') } catch { /* already dead */ }
|
|
640
|
+
setTimeout(() => {
|
|
641
|
+
try { process.kill(pid as number, 'SIGKILL') } catch { /* ok */ }
|
|
642
|
+
}, 10_000)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Write stop signal file (tells restart loop in other CLI processes to stop)
|
|
646
|
+
const stopFile = path.join(WORKSPACE_DIR, 'stop.signal')
|
|
647
|
+
try { fs.writeFileSync(stopFile, String(Date.now())) } catch { /* ok */ }
|
|
648
|
+
|
|
649
|
+
// Also kill any claw-node processes by name (covers orphans from restart loops)
|
|
650
|
+
try { execFileSync('pkill', ['-f', 'claw-node start'], { timeout: 3000 }) } catch { /* ok */ }
|
|
651
|
+
|
|
652
|
+
nodeProcess = null
|
|
653
|
+
nodeStartedAt = null
|
|
654
|
+
lastHealth = { blockHeight: null, peerCount: null, syncing: false }
|
|
655
|
+
try { fs.unlinkSync(pidFile) } catch { /* ok */ }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ============================================================
|
|
659
|
+
// Agent Registration
|
|
660
|
+
// ============================================================
|
|
661
|
+
|
|
662
|
+
async function autoRegisterAgent(cfg: PluginConfig, wallet: WalletData, api: OpenClawApi): Promise<void> {
|
|
663
|
+
if (!cfg.autoRegisterAgent) return
|
|
664
|
+
if (!wallet.address) return
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
const agent = await rpcCall(cfg.rpcPort, 'claw_getAgent', [wallet.address])
|
|
668
|
+
if (agent) {
|
|
669
|
+
api.logger?.info?.(`[clawnetwork] agent already registered on-chain: ${wallet.address.slice(0, 12)}...`)
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
} catch {
|
|
673
|
+
return // Node not ready
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (cfg.network === 'testnet' || cfg.network === 'devnet') {
|
|
677
|
+
try {
|
|
678
|
+
await rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
|
|
679
|
+
api.logger?.info?.('[clawnetwork] faucet: received testnet CLAW')
|
|
680
|
+
} catch { /* ok */ }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const binary = findBinary()
|
|
684
|
+
if (!binary) return
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
const agentName = sanitizeAgentName(`openclaw-${wallet.address.slice(0, 8)}`)
|
|
688
|
+
const output = execFileSync(binary, [
|
|
689
|
+
'agent', 'register', '--name', agentName,
|
|
690
|
+
'--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
691
|
+
], {
|
|
692
|
+
encoding: 'utf8',
|
|
693
|
+
timeout: 30_000,
|
|
694
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
695
|
+
})
|
|
696
|
+
api.logger?.info?.(`[clawnetwork] agent registered: ${agentName} — ${output.trim().slice(0, 200)}`)
|
|
697
|
+
} catch (e: unknown) {
|
|
698
|
+
api.logger?.warn?.(`[clawnetwork] agent registration skipped: ${(e as Error).message}`)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ============================================================
|
|
703
|
+
// WebUI Server
|
|
704
|
+
// ============================================================
|
|
705
|
+
|
|
706
|
+
function buildUiHtml(cfg: PluginConfig): string {
|
|
707
|
+
return `<!DOCTYPE html>
|
|
708
|
+
<html lang="en">
|
|
709
|
+
<head>
|
|
710
|
+
<meta charset="UTF-8">
|
|
711
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
712
|
+
<title>ClawNetwork Node Dashboard</title>
|
|
713
|
+
<link rel="icon" href="https://cdn.clawlabz.xyz/brand/favicon.png">
|
|
714
|
+
<style>
|
|
715
|
+
:root {
|
|
716
|
+
--bg: #0a0a12;
|
|
717
|
+
--bg-panel: #12121f;
|
|
718
|
+
--border: #1e1e3a;
|
|
719
|
+
--accent: #00ccff;
|
|
720
|
+
--accent-dim: rgba(0, 204, 255, 0.15);
|
|
721
|
+
--green: #00ff88;
|
|
722
|
+
--green-dim: rgba(0, 255, 136, 0.15);
|
|
723
|
+
--purple: #8b5cf6;
|
|
724
|
+
--text: #e0e0f0;
|
|
725
|
+
--text-dim: #666688;
|
|
726
|
+
--danger: #ff4455;
|
|
727
|
+
--font: system-ui, -apple-system, sans-serif;
|
|
728
|
+
--font-mono: 'SF Mono', 'Fira Code', Consolas, monospace;
|
|
729
|
+
--radius: 10px;
|
|
730
|
+
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
731
|
+
}
|
|
732
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
733
|
+
body { background: var(--bg); color: var(--text); font-family: var(--font); line-height: 1.6; min-height: 100vh; }
|
|
734
|
+
.container { max-width: 960px; margin: 0 auto; padding: 0 20px; }
|
|
735
|
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
|
736
|
+
|
|
737
|
+
.header { background: var(--bg-panel); border-bottom: 1px solid var(--border); padding: 16px 0; position: sticky; top: 0; z-index: 100; }
|
|
738
|
+
.header .container { display: flex; align-items: center; justify-content: space-between; }
|
|
739
|
+
.logo { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
|
|
740
|
+
.logo-claw { color: var(--accent); }
|
|
741
|
+
.logo-net { color: var(--green); }
|
|
742
|
+
.header-badge { font-size: 11px; background: var(--accent-dim); color: var(--accent); padding: 2px 8px; border-radius: 4px; }
|
|
743
|
+
|
|
744
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 24px 0; }
|
|
745
|
+
.stat-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
|
|
746
|
+
.stat-label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
|
|
747
|
+
.stat-value { font-size: 28px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; }
|
|
748
|
+
.stat-value.green { color: var(--green); }
|
|
749
|
+
.stat-value.accent { color: var(--accent); }
|
|
750
|
+
.stat-value.danger { color: var(--danger); }
|
|
751
|
+
|
|
752
|
+
.panel { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin: 16px 0; }
|
|
753
|
+
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
|
754
|
+
.info-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
|
755
|
+
.info-row:last-child { border-bottom: none; }
|
|
756
|
+
.info-label { color: var(--text-dim); }
|
|
757
|
+
.info-value { font-family: var(--font-mono); color: var(--text); word-break: break-all; max-width: 60%; text-align: right; }
|
|
758
|
+
|
|
759
|
+
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
|
|
760
|
+
.status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
|
761
|
+
.status-dot.offline { background: var(--danger); }
|
|
762
|
+
.status-dot.syncing { background: #ffaa00; animation: pulse 1.5s infinite; }
|
|
763
|
+
|
|
764
|
+
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); font-size: 13px; cursor: pointer; transition: 0.2s; }
|
|
765
|
+
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
766
|
+
.btn.danger:hover { border-color: var(--danger); color: var(--danger); }
|
|
767
|
+
.btn.primary { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
|
768
|
+
.btn-group { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
|
|
769
|
+
|
|
770
|
+
.logs-box { background: #080810; border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: var(--font-mono); font-size: 12px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; color: var(--text-dim); line-height: 1.8; }
|
|
771
|
+
|
|
772
|
+
.wallet-addr { font-family: var(--font-mono); font-size: 13px; background: var(--bg); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); word-break: break-all; display: flex; align-items: center; gap: 8px; }
|
|
773
|
+
.copy-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 14px; padding: 2px 6px; }
|
|
774
|
+
.copy-btn:hover { opacity: 0.7; }
|
|
775
|
+
|
|
776
|
+
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg-panel); border: 1px solid var(--accent); color: var(--accent); padding: 12px 20px; border-radius: 8px; font-size: 13px; opacity: 0; transition: 0.3s; z-index: 1000; }
|
|
777
|
+
.toast.show { opacity: 1; }
|
|
778
|
+
</style>
|
|
779
|
+
</head>
|
|
780
|
+
<body>
|
|
781
|
+
<header class="header">
|
|
782
|
+
<div class="container">
|
|
783
|
+
<div style="display:flex;align-items:center;gap:14px">
|
|
784
|
+
<div class="logo"><span class="logo-claw">Claw</span><span class="logo-net">Network</span></div>
|
|
785
|
+
<span class="header-badge">Node Dashboard</span>
|
|
786
|
+
</div>
|
|
787
|
+
<span id="lastUpdate" style="font-size:12px;color:var(--text-dim)"></span>
|
|
788
|
+
</div>
|
|
789
|
+
</header>
|
|
790
|
+
|
|
791
|
+
<main class="container" style="padding-top:8px;padding-bottom:40px">
|
|
792
|
+
<div class="stats-grid">
|
|
793
|
+
<div class="stat-card">
|
|
794
|
+
<div class="stat-label">Status</div>
|
|
795
|
+
<div class="stat-value" id="statusValue"><span class="status-dot offline"></span>Offline</div>
|
|
796
|
+
</div>
|
|
797
|
+
<div class="stat-card">
|
|
798
|
+
<div class="stat-label">Block Height</div>
|
|
799
|
+
<div class="stat-value accent" id="heightValue">—</div>
|
|
800
|
+
</div>
|
|
801
|
+
<div class="stat-card">
|
|
802
|
+
<div class="stat-label">Peers</div>
|
|
803
|
+
<div class="stat-value" id="peersValue">—</div>
|
|
804
|
+
</div>
|
|
805
|
+
<div class="stat-card">
|
|
806
|
+
<div class="stat-label">Uptime</div>
|
|
807
|
+
<div class="stat-value" id="uptimeValue">—</div>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
|
|
811
|
+
<div class="btn-group">
|
|
812
|
+
<button class="btn primary" onclick="doAction('start')">Start Node</button>
|
|
813
|
+
<button class="btn danger" onclick="doAction('stop')">Stop Node</button>
|
|
814
|
+
<button class="btn" onclick="doAction('faucet')">Faucet (testnet)</button>
|
|
815
|
+
<button class="btn" onclick="refreshLogs()">Refresh Logs</button>
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
<div class="panel">
|
|
819
|
+
<div class="panel-title">Wallet</div>
|
|
820
|
+
<div id="walletInfo">Loading...</div>
|
|
821
|
+
</div>
|
|
822
|
+
|
|
823
|
+
<div class="panel">
|
|
824
|
+
<div class="panel-title">Node Info</div>
|
|
825
|
+
<div id="nodeInfo">Loading...</div>
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
<div class="panel">
|
|
829
|
+
<div class="panel-title">Recent Logs</div>
|
|
830
|
+
<div class="logs-box" id="logsBox">Loading...</div>
|
|
831
|
+
</div>
|
|
832
|
+
</main>
|
|
833
|
+
|
|
834
|
+
<div class="toast" id="toast"></div>
|
|
835
|
+
|
|
836
|
+
<script>
|
|
837
|
+
const API = '';
|
|
838
|
+
let autoRefresh = null;
|
|
839
|
+
|
|
840
|
+
function toast(msg) {
|
|
841
|
+
const el = document.getElementById('toast');
|
|
842
|
+
el.textContent = msg;
|
|
843
|
+
el.classList.add('show');
|
|
844
|
+
setTimeout(() => el.classList.remove('show'), 3000);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function copyText(text) {
|
|
848
|
+
navigator.clipboard.writeText(text).then(() => toast('Copied!')).catch(() => {});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function fetchStatus() {
|
|
852
|
+
try {
|
|
853
|
+
const res = await fetch(API + '/api/status');
|
|
854
|
+
const data = await res.json();
|
|
855
|
+
renderStatus(data);
|
|
856
|
+
document.getElementById('lastUpdate').textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
|
857
|
+
} catch (e) { console.error(e); }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function renderStatus(s) {
|
|
861
|
+
const statusEl = document.getElementById('statusValue');
|
|
862
|
+
if (s.running) {
|
|
863
|
+
const dotClass = s.syncing ? 'syncing' : 'online';
|
|
864
|
+
const label = s.syncing ? 'Syncing' : 'Online';
|
|
865
|
+
statusEl.innerHTML = '<span class="status-dot ' + dotClass + '"></span>' + label;
|
|
866
|
+
statusEl.className = 'stat-value green';
|
|
867
|
+
} else {
|
|
868
|
+
statusEl.innerHTML = '<span class="status-dot offline"></span>Offline';
|
|
869
|
+
statusEl.className = 'stat-value danger';
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
document.getElementById('heightValue').textContent = s.blockHeight !== null ? s.blockHeight.toLocaleString() : '—';
|
|
873
|
+
document.getElementById('peersValue').textContent = s.peerCount !== null ? s.peerCount : '—';
|
|
874
|
+
document.getElementById('uptimeValue').textContent = s.uptimeFormatted || '—';
|
|
875
|
+
|
|
876
|
+
// Wallet
|
|
877
|
+
const wHtml = s.walletAddress
|
|
878
|
+
? '<div class="wallet-addr">' + s.walletAddress + ' <button class="copy-btn" onclick="copyText(\\''+s.walletAddress+'\\')">Copy</button></div>' +
|
|
879
|
+
(s.balance ? '<div style="margin-top:8px;font-size:14px;color:var(--green)">' + s.balance + '</div>' : '')
|
|
880
|
+
: '<div style="color:var(--text-dim)">No wallet yet — start the node to generate one</div>';
|
|
881
|
+
document.getElementById('walletInfo').innerHTML = wHtml;
|
|
882
|
+
|
|
883
|
+
// Node info
|
|
884
|
+
const rows = [
|
|
885
|
+
['Network', s.network],
|
|
886
|
+
['Sync Mode', s.syncMode],
|
|
887
|
+
['RPC URL', s.rpcUrl],
|
|
888
|
+
['Binary Version', s.binaryVersion || '—'],
|
|
889
|
+
['Plugin Version', s.pluginVersion],
|
|
890
|
+
['PID', s.pid || '—'],
|
|
891
|
+
['Restart Count', s.restartCount],
|
|
892
|
+
['Data Dir', s.dataDir],
|
|
893
|
+
];
|
|
894
|
+
document.getElementById('nodeInfo').innerHTML = rows.map(function(r) {
|
|
895
|
+
return '<div class="info-row"><span class="info-label">' + r[0] + '</span><span class="info-value">' + r[1] + '</span></div>';
|
|
896
|
+
}).join('');
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function doAction(action) {
|
|
900
|
+
try {
|
|
901
|
+
const res = await fetch(API + '/api/action/' + action, { method: 'POST' });
|
|
902
|
+
const data = await res.json();
|
|
903
|
+
toast(data.message || data.error || 'Done');
|
|
904
|
+
setTimeout(fetchStatus, 1500);
|
|
905
|
+
} catch (e) { toast('Error: ' + e.message); }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function refreshLogs() {
|
|
909
|
+
try {
|
|
910
|
+
const res = await fetch(API + '/api/logs');
|
|
911
|
+
const data = await res.json();
|
|
912
|
+
const box = document.getElementById('logsBox');
|
|
913
|
+
box.textContent = data.logs || 'No logs yet';
|
|
914
|
+
box.scrollTop = box.scrollHeight;
|
|
915
|
+
} catch (e) { document.getElementById('logsBox').textContent = 'Failed to load logs'; }
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
fetchStatus();
|
|
919
|
+
refreshLogs();
|
|
920
|
+
autoRefresh = setInterval(fetchStatus, 10000);
|
|
921
|
+
</script>
|
|
922
|
+
</body>
|
|
923
|
+
</html>`
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let uiServer: unknown = null
|
|
927
|
+
|
|
928
|
+
function startUiServer(cfg: PluginConfig, api: OpenClawApi): string | null {
|
|
929
|
+
const http = require('http')
|
|
930
|
+
|
|
931
|
+
if (uiServer) return null
|
|
932
|
+
|
|
933
|
+
let actualPort = cfg.uiPort
|
|
934
|
+
const maxRetries = 10
|
|
935
|
+
|
|
936
|
+
const handleRequest = async (req: { method: string; url: string }, res: { writeHead: (s: number, h?: Record<string, string>) => void; end: (data?: string) => void; setHeader: (k: string, v: string) => void }) => {
|
|
937
|
+
const url = new URL(req.url, `http://localhost:${actualPort}`)
|
|
938
|
+
const pathname = url.pathname
|
|
939
|
+
|
|
940
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
941
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
942
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
943
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
|
|
944
|
+
|
|
945
|
+
const json = (status: number, data: unknown) => {
|
|
946
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
947
|
+
res.end(JSON.stringify(data))
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Serve dashboard
|
|
951
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
952
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
953
|
+
res.end(buildUiHtml(cfg))
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Status API
|
|
958
|
+
if (pathname === '/api/status' && req.method === 'GET') {
|
|
959
|
+
const health = await checkHealth(cfg.rpcPort)
|
|
960
|
+
lastHealth = health
|
|
961
|
+
const status = buildStatus(cfg)
|
|
962
|
+
// Enrich with balance
|
|
963
|
+
let balance = ''
|
|
964
|
+
const wallet = loadWallet()
|
|
965
|
+
if (wallet?.address) {
|
|
966
|
+
try {
|
|
967
|
+
const raw = await rpcCall(cfg.rpcPort, 'claw_getBalance', [wallet.address])
|
|
968
|
+
balance = formatClaw(String(raw as string))
|
|
969
|
+
} catch { /* ok */ }
|
|
970
|
+
}
|
|
971
|
+
json(200, { ...status, balance, syncing: health.syncing })
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Logs API
|
|
976
|
+
if (pathname === '/api/logs' && req.method === 'GET') {
|
|
977
|
+
try {
|
|
978
|
+
if (!fs.existsSync(LOG_PATH)) { json(200, { logs: 'No logs yet' }); return }
|
|
979
|
+
const content = fs.readFileSync(LOG_PATH, 'utf8')
|
|
980
|
+
const lines = content.split('\n')
|
|
981
|
+
json(200, { logs: lines.slice(-80).join('\n') })
|
|
982
|
+
} catch (e: unknown) {
|
|
983
|
+
json(500, { error: (e as Error).message })
|
|
984
|
+
}
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Action API
|
|
989
|
+
if (pathname.startsWith('/api/action/') && req.method === 'POST') {
|
|
990
|
+
const action = pathname.split('/').pop()
|
|
991
|
+
|
|
992
|
+
if (action === 'start') {
|
|
993
|
+
let binary = findBinary()
|
|
994
|
+
if (!binary && cfg.autoDownload) {
|
|
995
|
+
try { binary = await downloadBinary(api) } catch (e: unknown) {
|
|
996
|
+
json(500, { error: (e as Error).message }); return
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (!binary) { json(400, { error: 'claw-node binary not found' }); return }
|
|
1000
|
+
if (nodeProcess && !nodeProcess.killed) { json(200, { message: 'Node already running' }); return }
|
|
1001
|
+
initNode(binary, cfg.network, api)
|
|
1002
|
+
startNodeProcess(binary, cfg, api)
|
|
1003
|
+
json(200, { message: 'Node starting...' })
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (action === 'stop') {
|
|
1008
|
+
stopNode(api)
|
|
1009
|
+
json(200, { message: 'Node stopped' })
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (action === 'faucet') {
|
|
1014
|
+
const wallet = loadWallet()
|
|
1015
|
+
if (!wallet?.address) { json(400, { error: 'No wallet' }); return }
|
|
1016
|
+
try {
|
|
1017
|
+
const result = await rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
|
|
1018
|
+
json(200, { message: 'Faucet success', ...(result as Record<string, unknown>) })
|
|
1019
|
+
} catch (e: unknown) {
|
|
1020
|
+
json(400, { error: (e as Error).message, message: 'Faucet only works on testnet/devnet' })
|
|
1021
|
+
}
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
json(404, { error: 'Unknown action' })
|
|
1026
|
+
return
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
json(404, { error: 'Not found' })
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Try ports with fallback
|
|
1033
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1034
|
+
const tryPort = cfg.uiPort + attempt
|
|
1035
|
+
try {
|
|
1036
|
+
const server = http.createServer((req: unknown, res: unknown) => {
|
|
1037
|
+
handleRequest(req as any, res as any).catch((err: Error) => {
|
|
1038
|
+
try { (res as any).writeHead(500); (res as any).end(JSON.stringify({ error: err.message })) } catch { /* ok */ }
|
|
1039
|
+
})
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
// Synchronous-ish listen check
|
|
1043
|
+
let bound = false
|
|
1044
|
+
server.listen(tryPort, '127.0.0.1')
|
|
1045
|
+
server.on('listening', () => { bound = true })
|
|
1046
|
+
server.on('error', () => { /* handled below */ })
|
|
1047
|
+
|
|
1048
|
+
// Give it a moment
|
|
1049
|
+
actualPort = tryPort
|
|
1050
|
+
uiServer = server
|
|
1051
|
+
|
|
1052
|
+
// Write port file for discovery
|
|
1053
|
+
ensureDir(path.dirname(UI_PORT_FILE))
|
|
1054
|
+
fs.writeFileSync(UI_PORT_FILE, JSON.stringify({ port: tryPort, pid: process.pid, startedAt: new Date().toISOString() }))
|
|
1055
|
+
|
|
1056
|
+
api.logger?.info?.(`[clawnetwork] dashboard: http://127.0.0.1:${tryPort}`)
|
|
1057
|
+
return `http://127.0.0.1:${tryPort}`
|
|
1058
|
+
} catch {
|
|
1059
|
+
continue
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
api.logger?.warn?.('[clawnetwork] failed to start dashboard UI server')
|
|
1064
|
+
return null
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function stopUiServer(): void {
|
|
1068
|
+
if (uiServer) {
|
|
1069
|
+
try { uiServer.close() } catch { /* ok */ }
|
|
1070
|
+
uiServer = null
|
|
1071
|
+
}
|
|
1072
|
+
try { fs.unlinkSync(UI_PORT_FILE) } catch { /* ok */ }
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function getDashboardUrl(): string | null {
|
|
1076
|
+
try {
|
|
1077
|
+
const raw = fs.readFileSync(UI_PORT_FILE, 'utf8')
|
|
1078
|
+
const info = JSON.parse(raw)
|
|
1079
|
+
return `http://127.0.0.1:${info.port}`
|
|
1080
|
+
} catch { return null }
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ============================================================
|
|
1084
|
+
// CLI Command Handlers
|
|
1085
|
+
// ============================================================
|
|
1086
|
+
|
|
1087
|
+
const CLI_COMMANDS = [
|
|
1088
|
+
'clawnetwork:status', 'clawnetwork:start', 'clawnetwork:stop',
|
|
1089
|
+
'clawnetwork:wallet', 'clawnetwork:wallet:import', 'clawnetwork:wallet:export',
|
|
1090
|
+
'clawnetwork:faucet', 'clawnetwork:transfer', 'clawnetwork:stake',
|
|
1091
|
+
'clawnetwork:logs', 'clawnetwork:config', 'clawnetwork:ui',
|
|
1092
|
+
'clawnetwork:service:register', 'clawnetwork:service:search',
|
|
1093
|
+
]
|
|
1094
|
+
|
|
1095
|
+
// ============================================================
|
|
1096
|
+
// Main Plugin Registration
|
|
1097
|
+
// ============================================================
|
|
1098
|
+
|
|
1099
|
+
export default function register(api: OpenClawApi) {
|
|
1100
|
+
const cfg = getConfig(api)
|
|
1101
|
+
|
|
1102
|
+
// ── Gateway Methods ──
|
|
1103
|
+
|
|
1104
|
+
api.registerGatewayMethod?.('clawnetwork.status', ctx => {
|
|
1105
|
+
checkHealth(cfg.rpcPort).then(health => {
|
|
1106
|
+
lastHealth = health
|
|
1107
|
+
ctx.respond?.(true, buildStatus(cfg) as unknown as Record<string, unknown>)
|
|
1108
|
+
}).catch(() => ctx.respond?.(true, buildStatus(cfg) as unknown as Record<string, unknown>))
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
api.registerGatewayMethod?.('clawnetwork.balance', ctx => {
|
|
1112
|
+
const address = (ctx.params?.address as string) || loadWallet()?.address || ''
|
|
1113
|
+
if (!address) { ctx.respond?.(false, { error: 'No wallet address' }); return }
|
|
1114
|
+
if (!isValidAddress(address)) { ctx.respond?.(false, { error: 'Invalid address format (expected 64-char hex)' }); return }
|
|
1115
|
+
rpcCall(cfg.rpcPort, 'claw_getBalance', [address])
|
|
1116
|
+
.then(result => ctx.respond?.(true, { address, balance: String(result), formatted: formatClaw(String(result as string)) }))
|
|
1117
|
+
.catch(err => ctx.respond?.(false, { error: (err as Error).message }))
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
api.registerGatewayMethod?.('clawnetwork.transfer', ctx => {
|
|
1121
|
+
const to = ctx.params?.to as string
|
|
1122
|
+
const amount = ctx.params?.amount as string
|
|
1123
|
+
if (!to || !amount) { ctx.respond?.(false, { error: 'Missing params: to, amount' }); return }
|
|
1124
|
+
if (!isValidAddress(to)) { ctx.respond?.(false, { error: 'Invalid address (expected 64-char hex)' }); return }
|
|
1125
|
+
if (!isValidAmount(amount)) { ctx.respond?.(false, { error: 'Invalid amount (must be positive number)' }); return }
|
|
1126
|
+
const binary = findBinary()
|
|
1127
|
+
if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found' }); return }
|
|
1128
|
+
try {
|
|
1129
|
+
const output = execFileSync(binary, [
|
|
1130
|
+
'transfer', to, amount, '--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
1131
|
+
], {
|
|
1132
|
+
encoding: 'utf8',
|
|
1133
|
+
timeout: 30_000,
|
|
1134
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1135
|
+
})
|
|
1136
|
+
const hashMatch = output.match(/[0-9a-f]{64}/i)
|
|
1137
|
+
ctx.respond?.(true, { txHash: hashMatch?.[0] ?? '', to, amount, raw: output.trim() })
|
|
1138
|
+
} catch (e: unknown) {
|
|
1139
|
+
ctx.respond?.(false, { error: (e as Error).message })
|
|
1140
|
+
}
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
api.registerGatewayMethod?.('clawnetwork.agent-register', ctx => {
|
|
1144
|
+
const rawName = (ctx.params?.name as string) || `openclaw-agent-${Date.now().toString(36)}`
|
|
1145
|
+
const name = sanitizeAgentName(rawName)
|
|
1146
|
+
if (!name || name.length < 2) { ctx.respond?.(false, { error: 'Invalid agent name' }); return }
|
|
1147
|
+
const binary = findBinary()
|
|
1148
|
+
if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found' }); return }
|
|
1149
|
+
try {
|
|
1150
|
+
const output = execFileSync(binary, [
|
|
1151
|
+
'agent', 'register', '--name', name,
|
|
1152
|
+
'--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
1153
|
+
], {
|
|
1154
|
+
encoding: 'utf8',
|
|
1155
|
+
timeout: 30_000,
|
|
1156
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1157
|
+
})
|
|
1158
|
+
const hashMatch = output.match(/[0-9a-f]{64}/i)
|
|
1159
|
+
ctx.respond?.(true, { txHash: hashMatch?.[0] ?? '', name, raw: output.trim() })
|
|
1160
|
+
} catch (e: unknown) {
|
|
1161
|
+
ctx.respond?.(false, { error: (e as Error).message })
|
|
1162
|
+
}
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
api.registerGatewayMethod?.('clawnetwork.faucet', ctx => {
|
|
1166
|
+
const wallet = loadWallet()
|
|
1167
|
+
if (!wallet?.address) { ctx.respond?.(false, { error: 'No wallet' }); return }
|
|
1168
|
+
rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
|
|
1169
|
+
.then(result => ctx.respond?.(true, result as Record<string, unknown>))
|
|
1170
|
+
.catch(err => ctx.respond?.(false, { error: (err as Error).message }))
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
api.registerGatewayMethod?.('clawnetwork.start', ctx => {
|
|
1174
|
+
const binary = findBinary()
|
|
1175
|
+
if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found. Set autoDownload=true or install manually.' }); return }
|
|
1176
|
+
if (nodeProcess && !nodeProcess.killed) { ctx.respond?.(true, { message: 'Node already running', ...buildStatus(cfg) }); return }
|
|
1177
|
+
initNode(binary, cfg.network, api)
|
|
1178
|
+
startNodeProcess(binary, cfg, api)
|
|
1179
|
+
ctx.respond?.(true, { message: 'Node starting...', ...buildStatus(cfg) })
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
api.registerGatewayMethod?.('clawnetwork.stop', ctx => {
|
|
1183
|
+
stopNode(api)
|
|
1184
|
+
ctx.respond?.(true, { message: 'Node stopped' })
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
api.registerGatewayMethod?.('clawnetwork.service-register', ctx => {
|
|
1188
|
+
const serviceType = ctx.params?.serviceType as string
|
|
1189
|
+
const endpoint = ctx.params?.endpoint as string
|
|
1190
|
+
const description = (ctx.params?.description as string) || ''
|
|
1191
|
+
const priceAmount = (ctx.params?.priceAmount as string) || '0'
|
|
1192
|
+
if (!serviceType || !endpoint) { ctx.respond?.(false, { error: 'Missing params: serviceType, endpoint' }); return }
|
|
1193
|
+
const binary = findBinary()
|
|
1194
|
+
if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found' }); return }
|
|
1195
|
+
try {
|
|
1196
|
+
const output = execFileSync(binary, [
|
|
1197
|
+
'service', 'register',
|
|
1198
|
+
'--type', serviceType,
|
|
1199
|
+
'--endpoint', endpoint,
|
|
1200
|
+
'--description', description,
|
|
1201
|
+
'--price', priceAmount,
|
|
1202
|
+
'--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
1203
|
+
], {
|
|
1204
|
+
encoding: 'utf8',
|
|
1205
|
+
timeout: 30_000,
|
|
1206
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1207
|
+
})
|
|
1208
|
+
ctx.respond?.(true, { ok: true, raw: output.trim() })
|
|
1209
|
+
} catch (e: unknown) {
|
|
1210
|
+
ctx.respond?.(false, { error: (e as Error).message })
|
|
1211
|
+
}
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
api.registerGatewayMethod?.('clawnetwork.service-search', ctx => {
|
|
1215
|
+
const serviceType = ctx.params?.serviceType as string | undefined
|
|
1216
|
+
const params = serviceType ? [serviceType] : []
|
|
1217
|
+
rpcCall(cfg.rpcPort, 'claw_getServices', params)
|
|
1218
|
+
.then(result => ctx.respond?.(true, { services: result }))
|
|
1219
|
+
.catch(err => ctx.respond?.(false, { error: (err as Error).message }))
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
// ── CLI Commands ──
|
|
1223
|
+
|
|
1224
|
+
api.registerCli?.(({ program }) => {
|
|
1225
|
+
function cmd(parent: CliCommandChain | CliProgram, name: string) {
|
|
1226
|
+
return parent.command(name).allowExcessArguments(true)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function out(data: unknown) {
|
|
1230
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n')
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const handleStatus = async () => {
|
|
1234
|
+
const health = await checkHealth(cfg.rpcPort)
|
|
1235
|
+
lastHealth = health
|
|
1236
|
+
const status = buildStatus(cfg)
|
|
1237
|
+
// Enrich with balance
|
|
1238
|
+
let balance = ''
|
|
1239
|
+
const wallet = loadWallet()
|
|
1240
|
+
if (wallet?.address && status.running) {
|
|
1241
|
+
try {
|
|
1242
|
+
const raw = await rpcCall(cfg.rpcPort, 'claw_getBalance', [wallet.address])
|
|
1243
|
+
balance = formatClaw(String(raw as string))
|
|
1244
|
+
} catch { /* ok */ }
|
|
1245
|
+
}
|
|
1246
|
+
const dashboard = getDashboardUrl()
|
|
1247
|
+
out({ ...status, balance: balance || undefined, dashboard: dashboard || undefined })
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const handleStart = async () => {
|
|
1251
|
+
// Check if already running (in-memory or detached via PID file)
|
|
1252
|
+
const state = isNodeRunning()
|
|
1253
|
+
if (state.running) {
|
|
1254
|
+
out({ message: 'Node already running', pid: state.pid })
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
let binary = findBinary()
|
|
1258
|
+
if (!binary) {
|
|
1259
|
+
if (cfg.autoDownload) {
|
|
1260
|
+
process.stdout.write('Downloading claw-node...\n')
|
|
1261
|
+
binary = await downloadBinary(api)
|
|
1262
|
+
} else {
|
|
1263
|
+
out({ error: 'claw-node not found. Run: curl -sSf https://raw.githubusercontent.com/clawlabz/claw-network/main/claw-node/scripts/install.sh | bash' })
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
initNode(binary, cfg.network, api)
|
|
1268
|
+
startNodeProcess(binary, cfg, api)
|
|
1269
|
+
out({ message: 'Node started', pid: nodeProcess?.pid, network: cfg.network, rpc: `http://localhost:${cfg.rpcPort}` })
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const handleStop = () => {
|
|
1273
|
+
stopNode(api)
|
|
1274
|
+
out({ message: 'Node stopped' })
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const handleWallet = async () => {
|
|
1278
|
+
const wallet = ensureWallet(cfg.network, api)
|
|
1279
|
+
let balance = 'unknown (node offline)'
|
|
1280
|
+
try {
|
|
1281
|
+
const raw = await rpcCall(cfg.rpcPort, 'claw_getBalance', [wallet.address])
|
|
1282
|
+
balance = formatClaw(String(raw as string))
|
|
1283
|
+
} catch { /* node might be down */ }
|
|
1284
|
+
out({ address: wallet.address, balance, network: wallet.network, createdAt: wallet.createdAt })
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const handleWalletImport = (privateKeyHex?: string) => {
|
|
1288
|
+
if (typeof privateKeyHex !== 'string' || !privateKeyHex) {
|
|
1289
|
+
process.stdout.write('Usage: openclaw clawnetwork wallet import <private-key-hex>\n')
|
|
1290
|
+
return
|
|
1291
|
+
}
|
|
1292
|
+
if (!isValidPrivateKey(privateKeyHex)) {
|
|
1293
|
+
out({ error: 'Invalid private key: must be 64 hex characters' })
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
const wallet: WalletData = {
|
|
1297
|
+
address: '',
|
|
1298
|
+
secretKey: privateKeyHex,
|
|
1299
|
+
createdAt: new Date().toISOString(),
|
|
1300
|
+
network: cfg.network,
|
|
1301
|
+
}
|
|
1302
|
+
const binary = findBinary()
|
|
1303
|
+
if (binary) {
|
|
1304
|
+
try {
|
|
1305
|
+
execFileSync(binary, ['key', 'import', privateKeyHex], {
|
|
1306
|
+
encoding: 'utf8',
|
|
1307
|
+
timeout: 10_000,
|
|
1308
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1309
|
+
})
|
|
1310
|
+
const showOut = execFileSync(binary, ['key', 'show'], {
|
|
1311
|
+
encoding: 'utf8',
|
|
1312
|
+
timeout: 5000,
|
|
1313
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1314
|
+
})
|
|
1315
|
+
const match = showOut.match(/[0-9a-f]{64}/i)
|
|
1316
|
+
if (match) wallet.address = match[0]
|
|
1317
|
+
} catch { /* ok */ }
|
|
1318
|
+
}
|
|
1319
|
+
saveWallet(wallet)
|
|
1320
|
+
out({ message: 'Wallet imported', address: wallet.address || '(address resolved on node start)' })
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const handleWalletExport = () => {
|
|
1324
|
+
const wallet = loadWallet()
|
|
1325
|
+
if (!wallet) { out({ error: 'No wallet found. Run: openclaw clawnetwork wallet' }); return }
|
|
1326
|
+
out({ address: wallet.address, secretKey: wallet.secretKey, _warning: 'NEVER share your secret key with anyone' })
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const handleFaucet = async () => {
|
|
1330
|
+
const wallet = ensureWallet(cfg.network, api)
|
|
1331
|
+
if (!wallet.address) { out({ error: 'No wallet address' }); return }
|
|
1332
|
+
try {
|
|
1333
|
+
const result = await rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
|
|
1334
|
+
out(result)
|
|
1335
|
+
} catch (e: unknown) {
|
|
1336
|
+
out({ error: (e as Error).message, hint: 'Faucet is only available on testnet/devnet' })
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const handleTransfer = async (to?: string, amount?: string) => {
|
|
1341
|
+
if (typeof to !== 'string' || typeof amount !== 'string') {
|
|
1342
|
+
process.stdout.write('Usage: openclaw clawnetwork transfer <to-address> <amount>\n')
|
|
1343
|
+
return
|
|
1344
|
+
}
|
|
1345
|
+
if (!isValidAddress(to)) { out({ error: 'Invalid address: must be 64 hex characters' }); return }
|
|
1346
|
+
if (!isValidAmount(amount)) { out({ error: 'Invalid amount: must be a positive number' }); return }
|
|
1347
|
+
const binary = findBinary()
|
|
1348
|
+
if (!binary) { out({ error: 'claw-node binary not found' }); return }
|
|
1349
|
+
try {
|
|
1350
|
+
const output = execFileSync(binary, [
|
|
1351
|
+
'transfer', to, amount, '--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
1352
|
+
], {
|
|
1353
|
+
encoding: 'utf8',
|
|
1354
|
+
timeout: 30_000,
|
|
1355
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1356
|
+
})
|
|
1357
|
+
out({ ok: true, to, amount, raw: output.trim() })
|
|
1358
|
+
} catch (e: unknown) {
|
|
1359
|
+
out({ error: (e as Error).message })
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const handleStake = async (amount?: string) => {
|
|
1364
|
+
if (typeof amount !== 'string') {
|
|
1365
|
+
process.stdout.write('Usage: openclaw clawnetwork stake <amount>\n')
|
|
1366
|
+
return
|
|
1367
|
+
}
|
|
1368
|
+
if (!isValidAmount(amount)) { out({ error: 'Invalid amount' }); return }
|
|
1369
|
+
const binary = findBinary()
|
|
1370
|
+
if (!binary) { out({ error: 'claw-node binary not found' }); return }
|
|
1371
|
+
try {
|
|
1372
|
+
const output = execFileSync(binary, [
|
|
1373
|
+
'stake', 'deposit', amount, '--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
1374
|
+
], {
|
|
1375
|
+
encoding: 'utf8',
|
|
1376
|
+
timeout: 30_000,
|
|
1377
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1378
|
+
})
|
|
1379
|
+
out({ ok: true, amount, raw: output.trim() })
|
|
1380
|
+
} catch (e: unknown) {
|
|
1381
|
+
out({ error: (e as Error).message })
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const handleServiceRegister = async (serviceType?: string, endpoint?: string) => {
|
|
1386
|
+
if (typeof serviceType !== 'string' || typeof endpoint !== 'string') {
|
|
1387
|
+
process.stdout.write('Usage: openclaw clawnetwork service register <type> <endpoint>\n')
|
|
1388
|
+
return
|
|
1389
|
+
}
|
|
1390
|
+
const binary = findBinary()
|
|
1391
|
+
if (!binary) { out({ error: 'claw-node binary not found' }); return }
|
|
1392
|
+
try {
|
|
1393
|
+
const output = execFileSync(binary, [
|
|
1394
|
+
'service', 'register', '--type', serviceType, '--endpoint', endpoint,
|
|
1395
|
+
'--rpc', `http://localhost:${cfg.rpcPort}`,
|
|
1396
|
+
], {
|
|
1397
|
+
encoding: 'utf8',
|
|
1398
|
+
timeout: 30_000,
|
|
1399
|
+
env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
|
|
1400
|
+
})
|
|
1401
|
+
out({ ok: true, serviceType, endpoint, raw: output.trim() })
|
|
1402
|
+
} catch (e: unknown) {
|
|
1403
|
+
out({ error: (e as Error).message })
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const handleServiceSearch = async (serviceType?: string) => {
|
|
1408
|
+
try {
|
|
1409
|
+
const params = typeof serviceType === 'string' ? [serviceType] : []
|
|
1410
|
+
const result = await rpcCall(cfg.rpcPort, 'claw_getServices', params)
|
|
1411
|
+
out({ services: result })
|
|
1412
|
+
} catch (e: unknown) {
|
|
1413
|
+
out({ error: (e as Error).message })
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const handleLogs = () => {
|
|
1418
|
+
if (!fs.existsSync(LOG_PATH)) { process.stdout.write('No logs yet\n'); return }
|
|
1419
|
+
const content = fs.readFileSync(LOG_PATH, 'utf8')
|
|
1420
|
+
const lines = content.split('\n')
|
|
1421
|
+
process.stdout.write(lines.slice(-80).join('\n') + '\n')
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const handleConfig = () => { out(cfg) }
|
|
1425
|
+
|
|
1426
|
+
const handleUi = async () => {
|
|
1427
|
+
const existing = getDashboardUrl()
|
|
1428
|
+
if (existing) {
|
|
1429
|
+
process.stdout.write(`Dashboard already running: ${existing}\n`)
|
|
1430
|
+
const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
|
|
1431
|
+
try { execFileSync(open, [existing], { timeout: 5000 }) } catch { /* ok */ }
|
|
1432
|
+
return
|
|
1433
|
+
}
|
|
1434
|
+
const url = startUiServer(cfg, api)
|
|
1435
|
+
if (url) {
|
|
1436
|
+
process.stdout.write(`Dashboard: ${url}\n`)
|
|
1437
|
+
const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
|
|
1438
|
+
try { execFileSync(open, [url], { timeout: 5000 }) } catch { /* ok */ }
|
|
1439
|
+
} else {
|
|
1440
|
+
out({ error: 'Failed to start dashboard' })
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Space format: `openclaw clawnetwork <sub>`
|
|
1445
|
+
const group = cmd(program, 'clawnetwork').description('ClawNetwork blockchain node management')
|
|
1446
|
+
cmd(group, 'status').description('Show node status').action(handleStatus)
|
|
1447
|
+
cmd(group, 'start').description('Start the blockchain node').action(handleStart)
|
|
1448
|
+
cmd(group, 'stop').description('Stop the blockchain node').action(handleStop)
|
|
1449
|
+
cmd(group, 'faucet').description('Get testnet CLAW from faucet').action(handleFaucet)
|
|
1450
|
+
cmd(group, 'transfer').description('Transfer CLAW').argument('<to>', 'Recipient address').argument('<amount>', 'Amount in CLAW').action(handleTransfer)
|
|
1451
|
+
cmd(group, 'stake').description('Stake CLAW').argument('<amount>', 'Amount to stake').action(handleStake)
|
|
1452
|
+
cmd(group, 'logs').description('Show recent node logs').action(handleLogs)
|
|
1453
|
+
cmd(group, 'config').description('Show current config').action(handleConfig)
|
|
1454
|
+
cmd(group, 'ui').description('Open visual dashboard').action(handleUi)
|
|
1455
|
+
|
|
1456
|
+
// Wallet subcommands
|
|
1457
|
+
const walletGroup = cmd(group, 'wallet').description('Wallet management')
|
|
1458
|
+
cmd(walletGroup, 'show').description('Show wallet address and balance').action(handleWallet)
|
|
1459
|
+
cmd(walletGroup, 'import').description('Import wallet from private key').argument('<key>', 'Private key hex').action(handleWalletImport)
|
|
1460
|
+
cmd(walletGroup, 'export').description('Export wallet private key').action(handleWalletExport)
|
|
1461
|
+
|
|
1462
|
+
// Service subcommands
|
|
1463
|
+
const serviceGroup = cmd(group, 'service').description('Service discovery')
|
|
1464
|
+
cmd(serviceGroup, 'register').description('Register a service').argument('<type>', 'Service type').argument('<endpoint>', 'Endpoint URL').action(handleServiceRegister)
|
|
1465
|
+
cmd(serviceGroup, 'search').description('Search services').argument('[type]', 'Filter by type').action(handleServiceSearch)
|
|
1466
|
+
|
|
1467
|
+
// Colon format: `openclaw clawnetwork:status`
|
|
1468
|
+
for (const prefix of ['clawnetwork']) {
|
|
1469
|
+
cmd(program, `${prefix}:status`).description('Show node status').action(handleStatus)
|
|
1470
|
+
cmd(program, `${prefix}:start`).description('Start the blockchain node').action(handleStart)
|
|
1471
|
+
cmd(program, `${prefix}:stop`).description('Stop the blockchain node').action(handleStop)
|
|
1472
|
+
cmd(program, `${prefix}:wallet`).description('Show wallet').action(handleWallet)
|
|
1473
|
+
cmd(program, `${prefix}:wallet:import`).description('Import wallet').argument('<key>', 'Private key hex').action(handleWalletImport)
|
|
1474
|
+
cmd(program, `${prefix}:wallet:export`).description('Export wallet').action(handleWalletExport)
|
|
1475
|
+
cmd(program, `${prefix}:faucet`).description('Get testnet CLAW').action(handleFaucet)
|
|
1476
|
+
cmd(program, `${prefix}:transfer`).description('Transfer CLAW').argument('<to>', 'Recipient').argument('<amount>', 'Amount').action(handleTransfer)
|
|
1477
|
+
cmd(program, `${prefix}:stake`).description('Stake CLAW').argument('<amount>', 'Amount').action(handleStake)
|
|
1478
|
+
cmd(program, `${prefix}:logs`).description('Show recent node logs').action(handleLogs)
|
|
1479
|
+
cmd(program, `${prefix}:config`).description('Show current config').action(handleConfig)
|
|
1480
|
+
cmd(program, `${prefix}:ui`).description('Open dashboard').action(handleUi)
|
|
1481
|
+
cmd(program, `${prefix}:service:register`).description('Register service').argument('<type>', 'Type').argument('<endpoint>', 'URL').action(handleServiceRegister)
|
|
1482
|
+
cmd(program, `${prefix}:service:search`).description('Search services').argument('[type]', 'Type filter').action(handleServiceSearch)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
}, { commands: CLI_COMMANDS })
|
|
1486
|
+
|
|
1487
|
+
// ── Service Lifecycle ──
|
|
1488
|
+
|
|
1489
|
+
api.registerService?.({
|
|
1490
|
+
id: 'clawnetwork-node',
|
|
1491
|
+
start: () => {
|
|
1492
|
+
api.logger?.info?.(`[clawnetwork] plugin v${VERSION} loaded, network=${cfg.network}, autoStart=${cfg.autoStart}`)
|
|
1493
|
+
|
|
1494
|
+
if (!cfg.autoStart) return
|
|
1495
|
+
|
|
1496
|
+
;(async () => {
|
|
1497
|
+
try {
|
|
1498
|
+
// Check if already running (e.g. from a previous detached start)
|
|
1499
|
+
const state = isNodeRunning()
|
|
1500
|
+
if (state.running) {
|
|
1501
|
+
api.logger?.info?.(`[clawnetwork] node already running (pid=${state.pid}), skipping auto-start`)
|
|
1502
|
+
startHealthCheck(cfg, api)
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Step 1: Ensure binary
|
|
1507
|
+
let binary = findBinary()
|
|
1508
|
+
if (!binary) {
|
|
1509
|
+
if (cfg.autoDownload) {
|
|
1510
|
+
api.logger?.info?.('[clawnetwork] claw-node not found, downloading...')
|
|
1511
|
+
binary = await downloadBinary(api)
|
|
1512
|
+
} else {
|
|
1513
|
+
api.logger?.error?.('[clawnetwork] claw-node not found and autoDownload is disabled')
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Step 2: Init
|
|
1519
|
+
initNode(binary, cfg.network, api)
|
|
1520
|
+
|
|
1521
|
+
// Step 3: Wallet
|
|
1522
|
+
const wallet = ensureWallet(cfg.network, api)
|
|
1523
|
+
|
|
1524
|
+
// Step 4: Start node
|
|
1525
|
+
startNodeProcess(binary, cfg, api)
|
|
1526
|
+
|
|
1527
|
+
// Step 5: Start UI dashboard
|
|
1528
|
+
startUiServer(cfg, api)
|
|
1529
|
+
|
|
1530
|
+
// Step 6: Auto-register agent (wait for node to sync)
|
|
1531
|
+
await sleep(15_000)
|
|
1532
|
+
await autoRegisterAgent(cfg, wallet, api)
|
|
1533
|
+
|
|
1534
|
+
} catch (err: unknown) {
|
|
1535
|
+
api.logger?.error?.(`[clawnetwork] startup failed: ${(err as Error).message}`)
|
|
1536
|
+
}
|
|
1537
|
+
})()
|
|
1538
|
+
},
|
|
1539
|
+
stop: () => {
|
|
1540
|
+
api.logger?.info?.('[clawnetwork] shutting down...')
|
|
1541
|
+
stopNode(api)
|
|
1542
|
+
stopUiServer()
|
|
1543
|
+
},
|
|
1544
|
+
})
|
|
1545
|
+
}
|