@agentlayer.tech/wallet 0.1.103 → 0.1.106

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.
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
5
- "version": "0.1.103",
5
+ "version": "0.1.106",
6
6
  "contracts": {
7
7
  "tools": [
8
8
  "agentlayer_autonomous_approve",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.103",
3
+ "version": "0.1.106",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.103
1
+ 0.1.106
@@ -403,14 +403,19 @@ sh agent-wallet/scripts/setup_evm_wallet.sh
403
403
  That wrapper:
404
404
 
405
405
  - prompts for `user-id` and EVM network when run interactively
406
- - defaults to `http://127.0.0.1:8081`
406
+ - defaults to a per-install unix socket at `~/.openclaw/wdk-evm-wallet/daemon.sock`
407
+ (or wherever `OPENCLAW_HOME` points); set `WDK_EVM_SERVICE_URL` to override
408
+ with an explicit `unix://` or `http://` URL for either transport
407
409
  - can auto-start `wdk-evm-wallet/run-local.sh` if the local service is not already healthy
408
410
  - creates or unlocks the local EVM wallet binding
409
411
  - also binds the paired EVM network by default: `ethereum <-> base` (GOAT remains an independent EVM network binding)
410
412
  - stores the entered EVM vault password into `sealed_keys.json` when `AGENT_WALLET_BOOT_KEY` is available, so later OpenClaw wallet switching can auto-raise the EVM backend without another password prompt
411
413
  - patches OpenClaw config to `backend=wdk_evm_local`
412
414
 
413
- Example host-side EVM wallet creation:
415
+ Example host-side EVM wallet creation (explicit TCP `wdkEvmServiceUrl` override —
416
+ TCP mode is an opt-in for advanced/remote deployments via `WDK_EVM_TRANSPORT=tcp`
417
+ on the `wdk-evm-wallet` side, and no longer gets the same protection against a
418
+ foreign daemon sharing the port that the unix-socket default gets by construction):
414
419
 
415
420
  ```bash
416
421
  printf '%s\n' 'your-local-evm-password' | \
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Keep in sync with package.json, pyproject.toml, and the npm installer version.
4
4
  # scripts/check_release_version.mjs enforces this on release.
5
- __version__ = "0.1.103"
5
+ __version__ = "0.1.106"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -44,7 +44,7 @@ class Settings(BaseSettings):
44
44
  wdk_btc_service_url: str = "http://127.0.0.1:8080"
45
45
  wdk_btc_wallet_id: str = ""
46
46
  wdk_btc_account_index: int = 0
47
- wdk_evm_service_url: str = "http://127.0.0.1:8081"
47
+ wdk_evm_service_url: str = ""
48
48
  wdk_evm_wallet_id: str = ""
49
49
  wdk_evm_account_index: int = 0
50
50
 
@@ -200,6 +200,20 @@ def resolve_openclaw_home() -> Path:
200
200
  return Path(raw).expanduser()
201
201
 
202
202
 
203
+ def resolve_wdk_evm_service_url() -> str:
204
+ """Resolve the wdk-evm-wallet service URL, unix-socket by default.
205
+
206
+ An explicit WDK_EVM_SERVICE_URL/settings value always wins, whatever
207
+ transport it names (http:// for an explicit TCP deployment, unix:// for
208
+ a non-default socket path). Otherwise this is a per-OPENCLAW_HOME unix
209
+ socket — see docs/superpowers/specs/2026-08-30-evm-wallet-unix-socket-transport-design.md.
210
+ """
211
+ explicit = settings.wdk_evm_service_url.strip()
212
+ if explicit:
213
+ return explicit
214
+ return f"unix://{resolve_openclaw_home() / 'wdk-evm-wallet' / 'daemon.sock'}"
215
+
216
+
203
217
  def default_solana_wallet_path(network: str) -> Path:
204
218
  """Return the default keypair path for a Solana wallet."""
205
219
  normalized_network = normalize_solana_network(network)
@@ -2,11 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import http.client
5
6
  import json
6
7
  import os
7
8
  import secrets
8
- import shutil
9
9
  import signal
10
+ import socket as socket_module
10
11
  import subprocess
11
12
  import time
12
13
  from pathlib import Path
@@ -20,6 +21,7 @@ from agent_wallet.config import (
20
21
  resolve_boot_key,
21
22
  resolve_evm_wallet_password,
22
23
  resolve_openclaw_home,
24
+ resolve_wdk_evm_service_url,
23
25
  settings,
24
26
  )
25
27
  from agent_wallet.file_ops import atomic_write_text
@@ -28,7 +30,6 @@ from agent_wallet.user_wallets import normalize_user_id
28
30
  from agent_wallet.wallet_layer.base import WalletBackendError
29
31
 
30
32
  LOCAL_WDK_EVM_HOSTS = {"127.0.0.1", "localhost", "::1"}
31
- _SERVICE_OWNER_FILENAME = "service-owner.json"
32
33
  _INSTANCE_ID_FILENAME = "instance-id"
33
34
 
34
35
 
@@ -37,7 +38,7 @@ def _normalize_evm_network(value: str | None) -> str:
37
38
 
38
39
 
39
40
  def _resolve_service_url(service_url: str | None = None) -> str:
40
- effective = (service_url or settings.wdk_evm_service_url).strip()
41
+ effective = (service_url or resolve_wdk_evm_service_url()).strip()
41
42
  if not effective:
42
43
  raise WalletBackendError("wdk_evm_service_url is required for EVM wallet host operations.")
43
44
  return effective
@@ -51,7 +52,22 @@ def _paired_network(network: str) -> str | None:
51
52
  return mapping.get(_normalize_evm_network(network))
52
53
 
53
54
 
55
+ class _UnixHealthConnection(http.client.HTTPConnection):
56
+ """Minimal HTTPConnection over AF_UNIX, for the unauthenticated /health probe."""
57
+
58
+ def __init__(self, socket_path: str, timeout: float = 1.5):
59
+ super().__init__("localhost", timeout=timeout)
60
+ self._socket_path = socket_path
61
+
62
+ def connect(self) -> None:
63
+ self.sock = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
64
+ self.sock.settimeout(self.timeout)
65
+ self.sock.connect(self._socket_path)
66
+
67
+
54
68
  def _health_url(service_url: str) -> str:
69
+ # Display/error-message use only now — unix:// targets are fetched via
70
+ # _UnixHealthConnection below, not urlopen, which has no unix-socket support.
55
71
  return f"{service_url.rstrip('/')}/health"
56
72
 
57
73
 
@@ -61,12 +77,24 @@ def _service_health(service_url: str) -> dict[str, Any] | None:
61
77
  An empty dict means the service answered 200 but the body was unparseable —
62
78
  treated as "running, version unknown" so we never restart on a parse blip.
63
79
  """
80
+ parsed = urlparse(service_url)
64
81
  try:
65
- with urlopen(_health_url(service_url), timeout=1.5) as response:
66
- if int(getattr(response, "status", 0) or 0) != 200:
67
- return None
68
- raw = response.read()
69
- except (URLError, TimeoutError, OSError):
82
+ if parsed.scheme == "unix":
83
+ conn = _UnixHealthConnection(parsed.path, timeout=1.5)
84
+ try:
85
+ conn.request("GET", "/health")
86
+ response = conn.getresponse()
87
+ if response.status != 200:
88
+ return None
89
+ raw = response.read()
90
+ finally:
91
+ conn.close()
92
+ else:
93
+ with urlopen(_health_url(service_url), timeout=1.5) as response:
94
+ if int(getattr(response, "status", 0) or 0) != 200:
95
+ return None
96
+ raw = response.read()
97
+ except (URLError, TimeoutError, OSError, http.client.HTTPException):
70
98
  return None
71
99
  try:
72
100
  payload = json.loads(raw.decode("utf-8"))
@@ -109,50 +137,6 @@ def _expected_local_service_instance_id() -> str:
109
137
  return generated
110
138
 
111
139
 
112
- def _service_owner_path() -> Path:
113
- return _expected_local_service_data_dir() / _SERVICE_OWNER_FILENAME
114
-
115
-
116
- def _read_service_owner() -> dict[str, Any] | None:
117
- try:
118
- payload = json.loads(_service_owner_path().read_text(encoding="utf-8"))
119
- except (OSError, ValueError):
120
- return None
121
- return payload if isinstance(payload, dict) else None
122
-
123
-
124
- def _write_service_owner(health: dict[str, Any], service_url: str) -> int:
125
- expected_instance = _expected_local_service_instance_id()
126
- instance_id = str(health.get("instanceId") or "").strip()
127
- try:
128
- pid = int(health.get("pid") or 0)
129
- except (TypeError, ValueError):
130
- pid = 0
131
- if instance_id != expected_instance or pid <= 0:
132
- raise WalletBackendError("wdk-evm-wallet health did not confirm local process ownership.")
133
- payload = {
134
- "version": 1,
135
- "pid": pid,
136
- "instance_id": instance_id,
137
- "port": urlparse(service_url).port or 8081,
138
- "data_dir": str(_expected_local_service_data_dir()),
139
- "service_version": str(health.get("version") or "").strip() or None,
140
- }
141
- atomic_write_text(_service_owner_path(), json.dumps(payload, indent=2) + "\n", mode=0o600)
142
- return pid
143
-
144
-
145
- def _same_path(left: str | Path | None, right: str | Path | None) -> bool:
146
- if left is None or right is None:
147
- return False
148
- try:
149
- left_path = Path(str(left)).expanduser().resolve()
150
- right_path = Path(str(right)).expanduser().resolve()
151
- except OSError:
152
- return False
153
- return left_path == right_path
154
-
155
-
156
140
  def _should_restart_local_service(
157
141
  health: dict[str, Any] | None,
158
142
  *,
@@ -162,42 +146,7 @@ def _should_restart_local_service(
162
146
  return False
163
147
  expected_version = _read_on_disk_service_version(wallet_root) if wallet_root is not None else None
164
148
  running_version = str(health.get("version") or "").strip()
165
- if expected_version and running_version and running_version != expected_version:
166
- return True
167
-
168
- reported_data_dir = str(health.get("dataDir") or "").strip()
169
- if reported_data_dir and not _same_path(reported_data_dir, _expected_local_service_data_dir()):
170
- return True
171
-
172
- reported_instance = str(health.get("instanceId") or "").strip()
173
- if reported_data_dir and reported_instance != _expected_local_service_instance_id():
174
- return True
175
-
176
- return False
177
-
178
-
179
- def _listening_pids(port: int) -> list[int]:
180
- """PIDs LISTENing on a local TCP port (via lsof), excluding our own."""
181
- lsof = shutil.which("lsof")
182
- if not lsof:
183
- return []
184
- try:
185
- completed = subprocess.run( # noqa: S603
186
- [lsof, "-t", "-i", f"tcp:{port}", "-sTCP:LISTEN"],
187
- capture_output=True,
188
- text=True,
189
- timeout=5,
190
- )
191
- except (OSError, subprocess.SubprocessError):
192
- return []
193
- pids: list[int] = []
194
- for token in completed.stdout.split():
195
- token = token.strip()
196
- if token.isdigit():
197
- pid = int(token)
198
- if pid != os.getpid():
199
- pids.append(pid)
200
- return pids
149
+ return bool(expected_version and running_version and running_version != expected_version)
201
150
 
202
151
 
203
152
  def _daemon_takeover_disabled() -> bool:
@@ -209,40 +158,13 @@ def _daemon_takeover_disabled() -> bool:
209
158
  }
210
159
 
211
160
 
212
- def _takeover_refusal(port: int, listeners: list[int], data_dir: str, reason: str) -> str:
213
- pids = ", ".join(str(pid) for pid in listeners) if listeners else "unknown"
161
+ def _takeover_refusal(reason: str) -> str:
214
162
  return (
215
- f"Refusing to stop the wdk-evm-wallet on port {port}: {reason}. "
216
- f"Listening PIDs: {pids}. Daemon dataDir: {data_dir or 'unknown'}. "
217
- f"To clear it manually: lsof -nP -iTCP:{port} -sTCP:LISTEN, then kill <pid>."
163
+ f"Refusing to stop wdk-evm-wallet: {reason}. "
164
+ "To clear it manually: check /health at the configured service URL for its pid, then kill <pid>."
218
165
  )
219
166
 
220
167
 
221
- def _process_cwd(pid: int) -> Path | None:
222
- """Return a process working directory via lsof, or None when unverified."""
223
- lsof = shutil.which("lsof")
224
- if not lsof:
225
- return None
226
- try:
227
- completed = subprocess.run( # noqa: S603
228
- [lsof, "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
229
- capture_output=True,
230
- text=True,
231
- timeout=5,
232
- )
233
- except (OSError, subprocess.SubprocessError):
234
- return None
235
- if completed.returncode != 0:
236
- return None
237
- for line in completed.stdout.splitlines():
238
- if line.startswith("n") and line[1:].strip():
239
- try:
240
- return Path(line[1:].strip()).resolve()
241
- except OSError:
242
- return None
243
- return None
244
-
245
-
246
168
  def _process_exists(pid: int) -> bool:
247
169
  try:
248
170
  os.kill(pid, 0)
@@ -253,129 +175,52 @@ def _process_exists(pid: int) -> bool:
253
175
  return True
254
176
 
255
177
 
256
- def _process_released_service(pid: int, port: int, service_url: str) -> bool:
178
+ def _process_released_service(pid: int, service_url: str) -> bool:
257
179
  if not _process_exists(pid):
258
180
  return True
259
- # A daemon signalled by a process other than its parent can remain briefly
260
- # as a zombie. Once its listener and health endpoint are both gone, it no
261
- # longer blocks the updated runtime and is considered released.
262
- return pid not in _listening_pids(port) and _service_health(service_url) is None
263
-
264
-
265
- def _owner_matches(
266
- owner: dict[str, Any] | None,
267
- current_health: dict[str, Any],
268
- *,
269
- pid: int,
270
- port: int,
271
- ) -> bool:
272
- if owner is None:
273
- return not _service_owner_path().exists()
274
- try:
275
- owner_pid = int(owner.get("pid") or 0)
276
- owner_port = int(owner.get("port") or 0)
277
- except (TypeError, ValueError):
278
- return False
279
- return (
280
- owner_pid == pid
281
- and owner_port == port
282
- and _same_path(owner.get("data_dir"), _expected_local_service_data_dir())
283
- and str(owner.get("instance_id") or "")
284
- == str(current_health.get("instanceId") or "")
285
- )
181
+ # A daemon signalled by a process other than its parent can remain
182
+ # briefly as a zombie. Once its health endpoint is gone it no longer
183
+ # blocks a fresh daemon from claiming the socket.
184
+ return _service_health(service_url) is None
286
185
 
287
186
 
288
- def _resolve_stoppable_pid(current_health: dict[str, Any], port: int) -> int:
289
- """Return a strictly verified same-home daemon PID, or 0 to fail closed."""
290
- reported_data_dir = str(current_health.get("dataDir") or "").strip()
291
- if not _same_path(reported_data_dir, _expected_local_service_data_dir()):
292
- return 0
293
- listeners = _listening_pids(port)
294
- if not listeners:
295
- return 0
187
+ def _resolve_stoppable_pid(current_health: dict[str, Any]) -> int:
188
+ """Return the daemon's reported PID, or 0 if it didn't report one."""
296
189
  try:
297
- reported_pid = int(current_health.get("pid") or 0)
190
+ pid = int(current_health.get("pid") or 0)
298
191
  except (TypeError, ValueError):
299
- reported_pid = 0
300
- if reported_pid > 0:
301
- if reported_pid not in listeners:
302
- return 0
303
- candidate = reported_pid
304
- elif len(listeners) == 1:
305
- # One-time compatibility for a pre-PID daemon. The listener and its
306
- # working directory still have to identify the bundled service.
307
- candidate = listeners[0]
308
- else:
309
- return 0
310
- cwd = _process_cwd(candidate)
311
- if cwd is None or cwd.name != "wdk-evm-wallet":
312
- return 0
313
- if not _owner_matches(_read_service_owner(), current_health, pid=candidate, port=port):
314
192
  return 0
315
- return candidate
193
+ return pid if pid > 0 else 0
316
194
 
317
195
 
318
196
  def _stop_local_service(service_url: str, health: dict[str, Any] | None = None) -> None:
319
197
  """Gracefully stop a local wdk-evm-wallet daemon so a fresh one can start.
320
198
 
321
- SIGTERM the listener(s), wait for /health to drop, then SIGKILL as a fallback.
199
+ SIGTERM the listener, wait for /health to drop, then SIGKILL as a fallback.
322
200
  """
323
201
  port = urlparse(service_url).port or 8081
324
202
  current_health = health if health is not None else _service_health(service_url)
325
203
  if not current_health or current_health.get("service") != "wdk-evm-wallet":
326
- raise WalletBackendError(
327
- f"Refusing to stop an unidentified service on port {port}."
328
- )
329
- listeners = _listening_pids(port)
330
- reported_data_dir = str(current_health.get("dataDir") or "").strip()
204
+ raise WalletBackendError(f"Refusing to stop an unidentified service on port {port}.")
331
205
 
332
206
  if _daemon_takeover_disabled():
333
207
  raise WalletBackendError(
334
- _takeover_refusal(
335
- port,
336
- listeners,
337
- reported_data_dir,
338
- "takeover is disabled by OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER",
339
- )
208
+ _takeover_refusal("takeover is disabled by OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER")
340
209
  )
341
210
 
342
- if not _same_path(reported_data_dir, _expected_local_service_data_dir()):
343
- raise WalletBackendError(
344
- _takeover_refusal(
345
- port,
346
- listeners,
347
- reported_data_dir,
348
- "the daemon belongs to a different wallet home",
349
- )
350
- )
351
-
352
- owned_pid = _resolve_stoppable_pid(current_health, port)
211
+ owned_pid = _resolve_stoppable_pid(current_health)
353
212
  if owned_pid <= 0:
354
213
  raise WalletBackendError(
355
- _takeover_refusal(
356
- port,
357
- listeners,
358
- reported_data_dir,
359
- "the listening process could not be identified",
360
- )
214
+ _takeover_refusal("the daemon's /health response did not include a pid")
361
215
  )
362
216
 
363
217
  try:
364
218
  os.kill(owned_pid, 0)
365
219
  except ProcessLookupError:
366
- try:
367
- _service_owner_path().unlink()
368
- except FileNotFoundError:
369
- pass
370
220
  return
371
221
  except PermissionError as exc:
372
222
  raise WalletBackendError(
373
- _takeover_refusal(
374
- port,
375
- listeners,
376
- reported_data_dir,
377
- f"pid {owned_pid} belongs to another user ({exc})",
378
- )
223
+ _takeover_refusal(f"pid {owned_pid} belongs to another user ({exc})")
379
224
  ) from exc
380
225
 
381
226
  try:
@@ -383,45 +228,31 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
383
228
  except ProcessLookupError:
384
229
  pass
385
230
  except PermissionError as exc:
386
- raise WalletBackendError(
387
- f"Cannot stop stale wdk-evm-wallet (pid {owned_pid}): {exc}."
388
- ) from exc
231
+ raise WalletBackendError(f"Cannot stop stale wdk-evm-wallet (pid {owned_pid}): {exc}.") from exc
232
+
389
233
  deadline = time.time() + 10.0
390
234
  while time.time() < deadline:
391
- if _process_released_service(owned_pid, port, service_url):
392
- try:
393
- _service_owner_path().unlink()
394
- except FileNotFoundError:
395
- pass
235
+ if _process_released_service(owned_pid, service_url):
396
236
  return
397
237
  time.sleep(0.3)
398
- # Re-run the socket/cwd/owner checks immediately before a hard stop. A PID
399
- # that exited and was reused must never inherit permission from old health.
238
+
239
+ # Re-check immediately before a hard stop. A PID that exited and was
240
+ # reused must never inherit permission from stale health.
400
241
  refreshed_health = _service_health(service_url)
401
242
  if (
402
243
  not refreshed_health
403
244
  or refreshed_health.get("service") != "wdk-evm-wallet"
404
- or _resolve_stoppable_pid(refreshed_health, port) != owned_pid
245
+ or _resolve_stoppable_pid(refreshed_health) != owned_pid
405
246
  ):
406
- raise WalletBackendError(
407
- _takeover_refusal(
408
- port,
409
- _listening_pids(port),
410
- str((refreshed_health or {}).get("dataDir") or ""),
411
- "process identity changed before the hard stop",
412
- )
413
- )
247
+ raise WalletBackendError(_takeover_refusal("process identity changed before the hard stop"))
248
+
414
249
  try:
415
250
  os.kill(owned_pid, signal.SIGKILL)
416
251
  except ProcessLookupError:
417
252
  pass
418
253
  deadline = time.time() + 5.0
419
254
  while time.time() < deadline:
420
- if _process_released_service(owned_pid, port, service_url):
421
- try:
422
- _service_owner_path().unlink()
423
- except FileNotFoundError:
424
- pass
255
+ if _process_released_service(owned_pid, service_url):
425
256
  return
426
257
  time.sleep(0.3)
427
258
  raise WalletBackendError(f"Failed to stop stale wdk-evm-wallet on port {port}.")
@@ -429,6 +260,11 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
429
260
 
430
261
  def _is_local_service_url(service_url: str) -> bool:
431
262
  parsed = urlparse(service_url)
263
+ if parsed.scheme == "unix":
264
+ # A unix socket is local by construction. Mirrors the same acceptance in
265
+ # scripts/bootstrap_openclaw_evm.py and providers/wdk_evm_local.py; without
266
+ # it _auto_start_local_service refuses the default service URL outright.
267
+ return bool(parsed.path)
432
268
  return parsed.scheme in {"http", "https"} and parsed.hostname in LOCAL_WDK_EVM_HOSTS
433
269
 
434
270
 
@@ -453,16 +289,14 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
453
289
  health = _service_health(service_url)
454
290
  if health is not None:
455
291
  # Already running. The daemon loads code once at boot (no hot-reload), so a
456
- # long-running process keeps serving stale code after a release. It can also
457
- # keep serving the wrong local vault after a temp/smoke install left another
458
- # daemon on the shared localhost port. Restart only when the local daemon no
459
- # longer matches the expected launcher version or expected dataDir. Remote
460
- # (non-local) healthy services we don't manage are left untouched.
292
+ # long-running process keeps serving stale code after a release. Restart
293
+ # only when the local daemon no longer matches the expected launcher
294
+ # version its socket path is scoped to this dataDir, so nothing foreign
295
+ # can answer here. Remote (non-local) healthy services we don't manage are
296
+ # left untouched.
461
297
  if not _is_local_service_url(service_url):
462
298
  return
463
299
  if not _should_restart_local_service(health, wallet_root=wallet_root):
464
- if str(health.get("instanceId") or "").strip():
465
- _write_service_owner(health, service_url)
466
300
  return
467
301
  _stop_local_service(service_url, health)
468
302
  if not _is_local_service_url(service_url):
@@ -475,8 +309,13 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
475
309
  )
476
310
  parsed = urlparse(service_url)
477
311
  env = os.environ.copy()
478
- env["HOST"] = parsed.hostname or "127.0.0.1"
479
- env["PORT"] = str(parsed.port or 8081)
312
+ if parsed.scheme == "unix":
313
+ env["WDK_EVM_TRANSPORT"] = "socket"
314
+ env["WDK_EVM_SOCKET_PATH"] = parsed.path
315
+ else:
316
+ env["WDK_EVM_TRANSPORT"] = "tcp"
317
+ env["HOST"] = parsed.hostname or "127.0.0.1"
318
+ env["PORT"] = str(parsed.port or 8081)
480
319
  env["WDK_EVM_NETWORK"] = _normalize_evm_network(network)
481
320
  env["WDK_EVM_INSTANCE_ID"] = _expected_local_service_instance_id()
482
321
  process = subprocess.Popen( # noqa: S603
@@ -492,7 +331,6 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
492
331
  while time.time() < deadline:
493
332
  health = _service_health(service_url)
494
333
  if health is not None:
495
- _write_service_owner(health, service_url)
496
334
  return
497
335
  if process.poll() is not None:
498
336
  raise WalletBackendError("wdk-evm-wallet exited before becoming healthy.")
@@ -502,6 +340,25 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
502
340
  )
503
341
 
504
342
 
343
+ def ensure_local_evm_service_ready(service_url: str, network: str) -> None:
344
+ """Public entry point for callers outside the OpenClaw user-wallet flow.
345
+
346
+ Thin wrapper around `_auto_start_local_service`, which was previously only
347
+ reachable through `ensure_user_evm_wallet_ready`/`resolve_user_evm_wallet_binding`
348
+ (the multi-user OpenClaw gateway/Hermes path). The single-agent factory
349
+ (`wallet_layer.factory.create_wallet_backend`) has no user_id and doesn't
350
+ need one — the underlying ownership/health checks are already keyed off
351
+ `service_url`/`OPENCLAW_HOME`, not the caller's user. Exposing this lets
352
+ both paths share one recovery implementation instead of drifting apart.
353
+
354
+ Raises `WalletBackendError` for anything the auto-start/eviction logic
355
+ can't resolve on its own (e.g. a foreign-home daemon occupying the port);
356
+ callers should let that surface as a clear tool error rather than a raw
357
+ connection failure.
358
+ """
359
+ _auto_start_local_service(service_url, network)
360
+
361
+
505
362
  def _resolve_user_evm_wallet_dir(user_id: str) -> Path:
506
363
  return resolve_openclaw_home() / "users" / normalize_user_id(user_id) / "wallets"
507
364
 
@@ -8,7 +8,12 @@ from typing import Any
8
8
  from agent_wallet.approval import issue_approval_token
9
9
  from agent_wallet.boot_key_migration import migrate_boot_key_to_keystore
10
10
  from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
11
- from agent_wallet.config import normalize_btc_network, normalize_evm_network, settings
11
+ from agent_wallet.config import (
12
+ normalize_btc_network,
13
+ normalize_evm_network,
14
+ resolve_wdk_evm_service_url,
15
+ settings,
16
+ )
12
17
  from agent_wallet.evm_user_wallets import ensure_user_evm_wallet_ready
13
18
  from agent_wallet.models import OpenClawWalletSessionMetadata
14
19
  from agent_wallet.openclaw_adapter import OpenClawWalletAdapter
@@ -181,7 +186,7 @@ def onboard_openclaw_user_wallet(
181
186
  )
182
187
 
183
188
  if backend_name in {"wdk_evm_local", "wdk-evm-local", "evm_local", "evm-local"}:
184
- service_url = str(wdk_evm_service_url or settings.wdk_evm_service_url).strip()
189
+ service_url = str(wdk_evm_service_url or resolve_wdk_evm_service_url()).strip()
185
190
  account_index = (
186
191
  settings.wdk_evm_account_index
187
192
  if wdk_evm_account_index is None