@agentlayer.tech/wallet 0.1.55 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
  2. package/.openclaw/extensions/agent-wallet/package.json +1 -1
  3. package/CHANGELOG.md +81 -0
  4. package/README.md +1 -1
  5. package/VERSION +1 -1
  6. package/agent-wallet/.env.example +1 -0
  7. package/agent-wallet/agent_wallet/__init__.py +1 -1
  8. package/agent-wallet/agent_wallet/boot_key_migration.py +158 -0
  9. package/agent-wallet/agent_wallet/boot_key_recovery.py +46 -0
  10. package/agent-wallet/agent_wallet/config.py +30 -1
  11. package/agent-wallet/agent_wallet/keystore.py +303 -0
  12. package/agent-wallet/agent_wallet/openclaw_cli.py +115 -39
  13. package/agent-wallet/agent_wallet/openclaw_runtime.py +28 -3
  14. package/agent-wallet/agent_wallet/providers/jupiter.py +34 -0
  15. package/agent-wallet/agent_wallet/user_wallets.py +46 -3
  16. package/agent-wallet/agent_wallet/wallet_layer/solana.py +34 -8
  17. package/agent-wallet/openclaw.plugin.json +1 -1
  18. package/agent-wallet/pyproject.toml +1 -1
  19. package/bin/openclaw-agent-wallet.mjs +73 -4
  20. package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
  21. package/claude-code/plugins/agent-wallet/commands/wallet-sol.md +9 -15
  22. package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
  23. package/codex/plugins/agent-wallet/README.md +9 -0
  24. package/codex/plugins/agent-wallet/server.py +353 -4
  25. package/codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md +29 -0
  26. package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
  27. package/package.json +1 -1
  28. package/wdk-btc-wallet/package.json +1 -1
  29. package/wdk-evm-wallet/package.json +1 -1
@@ -0,0 +1,303 @@
1
+ """Cross-platform, dependency-free secret storage for the wallet boot key.
2
+
3
+ Backends shell out to tools already present on each desktop OS:
4
+ - macOS: /usr/bin/security (generic password in the login keychain)
5
+ - Windows: powershell DPAPI (ConvertFrom/ConvertTo-SecureString), per-user+machine
6
+ - Linux: secret-tool (libsecret) iff a Secret Service is reachable
7
+ - fallback: a 0600 plaintext file (current behavior) — never chosen over a
8
+ working keystore.
9
+
10
+ The store holds ONE install-level secret (the boot key). See
11
+ BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Protocol, runtime_checkable
22
+
23
+ from agent_wallet.config import resolve_openclaw_home
24
+ from agent_wallet.file_ops import atomic_write_text, chmod_if_exists
25
+
26
+ KEYSTORE_SERVICE = "ai.agentlayer.wallet"
27
+ BOOT_KEY_ITEM = "boot_key"
28
+ _PROBE_ITEM = "__probe__"
29
+ _KEYSTORE_BACKEND_ENV = "AGENT_WALLET_KEYSTORE_BACKEND"
30
+
31
+ _SECURITY_BIN = "/usr/bin/security"
32
+ _SUBPROCESS_TIMEOUT = 10.0
33
+
34
+
35
+ def _service() -> str:
36
+ """Keychain/Secret-Service service name. Overridable so tests never touch the
37
+ real shared slot (the OS keychain is global, not scoped to OPENCLAW_HOME)."""
38
+ return os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip() or KEYSTORE_SERVICE
39
+
40
+
41
+ def _backend_preference() -> str:
42
+ """Selected keystore backend.
43
+
44
+ auto (default) prefers the native OS keystore (macOS Keychain / Windows DPAPI /
45
+ Linux Secret Service) and falls back to a 0600 plaintext file. macOS Keychain
46
+ is prompt-free because set() writes with `-A` (open ACL, no partition-list).
47
+ Override with AGENT_WALLET_KEYSTORE_BACKEND=plaintext|macos-keychain|native|...
48
+ """
49
+ return os.getenv(_KEYSTORE_BACKEND_ENV, "auto").strip().lower()
50
+
51
+
52
+ class KeyStoreError(Exception):
53
+ """Raised when a keystore backend operation fails unexpectedly."""
54
+
55
+
56
+ @runtime_checkable
57
+ class KeyStore(Protocol):
58
+ backend_id: str
59
+
60
+ def available(self) -> bool: ...
61
+ def get(self, name: str) -> str | None: ...
62
+ def set(self, name: str, value: str) -> None: ...
63
+ def delete(self, name: str) -> None: ...
64
+
65
+
66
+ def _timeout_output(value: str | bytes | None) -> str:
67
+ if value is None:
68
+ return ""
69
+ if isinstance(value, bytes):
70
+ return value.decode(errors="replace")
71
+ return value
72
+
73
+
74
+ def _run(
75
+ argv: list[str],
76
+ *,
77
+ input_text: str | None = None,
78
+ timeout: float = _SUBPROCESS_TIMEOUT,
79
+ ) -> subprocess.CompletedProcess[str]:
80
+ kwargs: dict[str, object] = {
81
+ "capture_output": True,
82
+ "text": True,
83
+ "timeout": timeout,
84
+ "check": False,
85
+ }
86
+ if input_text is None:
87
+ kwargs["stdin"] = subprocess.DEVNULL
88
+ else:
89
+ kwargs["input"] = input_text
90
+ try:
91
+ return subprocess.run(argv, **kwargs)
92
+ except subprocess.TimeoutExpired as exc:
93
+ return subprocess.CompletedProcess(
94
+ argv,
95
+ 124,
96
+ _timeout_output(exc.stdout),
97
+ _timeout_output(exc.stderr) or f"timed out after {timeout:g}s",
98
+ )
99
+
100
+
101
+ class MacKeychainStore:
102
+ backend_id = "macos-keychain"
103
+
104
+ def available(self) -> bool:
105
+ return platform.system() == "Darwin" and Path(_SECURITY_BIN).exists()
106
+
107
+ def get(self, name: str) -> str | None:
108
+ proc = _run([_SECURITY_BIN, "find-generic-password", "-s", _service(), "-a", name, "-w"])
109
+ if proc.returncode != 0:
110
+ return None # item not found (44) or other non-fatal lookup miss
111
+ value = proc.stdout.rstrip("\n")
112
+ return value or None
113
+
114
+ def set(self, name: str, value: str) -> None:
115
+ # Recreate the item fresh with an open ACL. This is the ONLY reliably
116
+ # prompt-free way to write a login-keychain item non-interactively:
117
+ # -A -> accessible to any application without a prompt, and it
118
+ # bypasses the Sierra+ partition-list mechanism entirely.
119
+ # delete+add -> never -U-update an existing item; updating one created
120
+ # with a different ACL (or setting its partition list)
121
+ # pops a GUI keychain-password dialog in the background.
122
+ # Trade-off: any process running as this user can read the key without a
123
+ # prompt — the same runtime exposure as a 0600 file. At-rest protection
124
+ # (backups, synced home dirs, a stolen disk) is fully preserved.
125
+ _run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
126
+ proc = _run([
127
+ _SECURITY_BIN, "add-generic-password",
128
+ "-s", _service(), "-a", name,
129
+ "-w", value, "-A",
130
+ ])
131
+ if proc.returncode != 0:
132
+ raise KeyStoreError(f"security add-generic-password failed: {proc.stderr.strip()}")
133
+
134
+ def delete(self, name: str) -> None:
135
+ _run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
136
+
137
+
138
+ class WindowsDpapiStore:
139
+ backend_id = "windows-dpapi"
140
+
141
+ def _blob_path(self, name: str) -> Path:
142
+ return resolve_openclaw_home() / "keystore" / f"{name}.dpapi"
143
+
144
+ def available(self) -> bool:
145
+ return platform.system() == "Windows" and shutil.which("powershell") is not None
146
+
147
+ def get(self, name: str) -> str | None:
148
+ path = self._blob_path(name)
149
+ if not path.exists():
150
+ return None
151
+ blob = path.read_text(encoding="utf-8").strip()
152
+ if not blob:
153
+ return None
154
+ script = (
155
+ "$ErrorActionPreference='Stop';"
156
+ "$b=[Console]::In.ReadToEnd().Trim();"
157
+ "$ss=ConvertTo-SecureString $b;"
158
+ "$p=[Runtime.InteropServices.Marshal]::PtrToStringUni("
159
+ "[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss));"
160
+ "[Console]::Out.Write($p)"
161
+ )
162
+ proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=blob)
163
+ if proc.returncode != 0:
164
+ return None
165
+ return proc.stdout or None
166
+
167
+ def set(self, name: str, value: str) -> None:
168
+ script = (
169
+ "$ErrorActionPreference='Stop';"
170
+ "$v=[Console]::In.ReadToEnd();"
171
+ "$ss=ConvertTo-SecureString $v -AsPlainText -Force;"
172
+ "[Console]::Out.Write((ConvertFrom-SecureString $ss))"
173
+ )
174
+ proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=value)
175
+ if proc.returncode != 0 or not proc.stdout.strip():
176
+ raise KeyStoreError(f"DPAPI ConvertFrom-SecureString failed: {proc.stderr.strip()}")
177
+ atomic_write_text(self._blob_path(name), proc.stdout.strip() + "\n", mode=0o600)
178
+
179
+ def delete(self, name: str) -> None:
180
+ path = self._blob_path(name)
181
+ try:
182
+ path.unlink()
183
+ except FileNotFoundError:
184
+ pass
185
+
186
+
187
+ class LinuxSecretServiceStore:
188
+ backend_id = "linux-secretservice"
189
+
190
+ def available(self) -> bool:
191
+ if platform.system() != "Linux" or shutil.which("secret-tool") is None:
192
+ return False
193
+ # A probe lookup succeeds (rc 0/1) only when a Secret Service answers;
194
+ # a missing/unreachable service errors out (rc >1) or times out.
195
+ try:
196
+ proc = _run(["secret-tool", "lookup", "service", _service(), "account", "__probe__"])
197
+ except subprocess.TimeoutExpired:
198
+ return False
199
+ return proc.returncode in (0, 1)
200
+
201
+ def get(self, name: str) -> str | None:
202
+ proc = _run(["secret-tool", "lookup", "service", _service(), "account", name])
203
+ if proc.returncode != 0:
204
+ return None
205
+ value = proc.stdout.rstrip("\n")
206
+ return value or None
207
+
208
+ def set(self, name: str, value: str) -> None:
209
+ proc = _run(
210
+ ["secret-tool", "store", "--label", f"{_service()} {name}",
211
+ "service", _service(), "account", name],
212
+ input_text=value,
213
+ )
214
+ if proc.returncode != 0:
215
+ raise KeyStoreError(f"secret-tool store failed: {proc.stderr.strip()}")
216
+
217
+ def delete(self, name: str) -> None:
218
+ _run(["secret-tool", "clear", "service", _service(), "account", name])
219
+
220
+
221
+ class PlaintextFileStore:
222
+ """Fallback: a 0600 file under OPENCLAW_HOME/keystore. Always available."""
223
+
224
+ backend_id = "plaintext-file"
225
+
226
+ def _path(self, name: str) -> Path:
227
+ return resolve_openclaw_home() / "keystore" / f"{name}.plaintext"
228
+
229
+ def available(self) -> bool:
230
+ return True
231
+
232
+ def get(self, name: str) -> str | None:
233
+ path = self._path(name)
234
+ if not path.exists():
235
+ return None
236
+ chmod_if_exists(path)
237
+ value = path.read_text(encoding="utf-8").strip()
238
+ return value or None
239
+
240
+ def set(self, name: str, value: str) -> None:
241
+ atomic_write_text(self._path(name), value.strip() + "\n", mode=0o600)
242
+
243
+ def delete(self, name: str) -> None:
244
+ try:
245
+ self._path(name).unlink()
246
+ except FileNotFoundError:
247
+ pass
248
+
249
+
250
+ def resolve_keystore() -> KeyStore:
251
+ """Return the first available OS-native backend, else the plaintext fallback."""
252
+ preference = _backend_preference()
253
+ if preference in {"plain", "plaintext", "plaintext-file", "file"}:
254
+ return PlaintextFileStore()
255
+
256
+ candidates: list[KeyStore]
257
+ if preference in {"macos", "macos-keychain", "keychain"}:
258
+ candidates = [MacKeychainStore()]
259
+ elif preference in {"windows", "windows-dpapi", "dpapi"}:
260
+ candidates = [WindowsDpapiStore()]
261
+ elif preference in {"linux", "linux-secretservice", "secretservice"}:
262
+ candidates = [LinuxSecretServiceStore()]
263
+ elif preference == "native":
264
+ candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
265
+ else:
266
+ # Auto: native OS keystore first, plaintext fallback last. macOS Keychain
267
+ # is prompt-free now that set() uses `-A` (open ACL, no partition-list) and
268
+ # _backend_usable falls back gracefully if a session still can't authorize.
269
+ candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
270
+
271
+ for candidate in candidates:
272
+ try:
273
+ if candidate.available() and _backend_usable(candidate):
274
+ return candidate
275
+ except Exception:
276
+ continue
277
+ return PlaintextFileStore()
278
+
279
+
280
+ def _backend_usable(candidate: KeyStore) -> bool:
281
+ """Return true when a native backend can read the live key or write safely.
282
+
283
+ A desktop keystore binary can exist while the session cannot authorize
284
+ non-interactive writes. Treat that as unavailable so install/migration uses
285
+ the bounded fallback instead of hanging or repeatedly failing.
286
+ """
287
+ try:
288
+ if candidate.get(BOOT_KEY_ITEM):
289
+ return True
290
+ except Exception:
291
+ pass
292
+
293
+ probe_value = "ok"
294
+ try:
295
+ candidate.set(_PROBE_ITEM, probe_value)
296
+ return candidate.get(_PROBE_ITEM) == probe_value
297
+ except Exception:
298
+ return False
299
+ finally:
300
+ try:
301
+ candidate.delete(_PROBE_ITEM)
302
+ except Exception:
303
+ pass
@@ -33,6 +33,12 @@ def _parse_csv(value: Any) -> str:
33
33
 
34
34
 
35
35
  SECRET_CONFIG_KEYS = {"privateKey", "masterKey", "approvalSecret"}
36
+ READ_ONLY_WORKER_TOOLS = frozenset(
37
+ {
38
+ "get_wallet_balance",
39
+ "get_wallet_portfolio",
40
+ }
41
+ )
36
42
 
37
43
 
38
44
  def _reject_secret_config_json(config: dict[str, Any]) -> None:
@@ -117,6 +123,9 @@ def _apply_config_overrides(config: dict[str, Any]) -> None:
117
123
  if not allow_override and os.getenv(env_name, "").strip():
118
124
  continue
119
125
  os.environ[env_name] = text
126
+ from agent_wallet.config import reload_settings
127
+
128
+ reload_settings()
120
129
 
121
130
 
122
131
  def _load_json(raw: str | None, default: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -138,10 +147,23 @@ def _read_stdin_secret(field_name: str) -> str:
138
147
  async def _run_onboard(user_id: str, config: dict[str, Any]) -> dict[str, Any]:
139
148
  from agent_wallet.openclaw_runtime import onboard_openclaw_user_wallet
140
149
 
141
- context = onboard_openclaw_user_wallet(
150
+ context = _build_runtime_context(user_id, config)
151
+ return context.serializable_bundle()
152
+
153
+
154
+ def _build_runtime_context(
155
+ user_id: str,
156
+ config: dict[str, Any],
157
+ *,
158
+ read_only: bool = False,
159
+ ) -> Any:
160
+ from agent_wallet.openclaw_runtime import onboard_openclaw_user_wallet
161
+
162
+ return onboard_openclaw_user_wallet(
142
163
  user_id,
143
164
  backend=config.get("backend"),
144
165
  sign_only=config.get("signOnly"),
166
+ read_only=read_only,
145
167
  network=config.get("network"),
146
168
  rpc_url=config.get("rpcUrl"),
147
169
  wdk_btc_service_url=config.get("wdkBtcServiceUrl"),
@@ -151,7 +173,6 @@ async def _run_onboard(user_id: str, config: dict[str, Any]) -> dict[str, Any]:
151
173
  wdk_evm_wallet_id=config.get("wdkEvmWalletId"),
152
174
  wdk_evm_account_index=config.get("wdkEvmAccountIndex"),
153
175
  )
154
- return context.serializable_bundle()
155
176
 
156
177
 
157
178
  async def _run_invoke(
@@ -160,21 +181,7 @@ async def _run_invoke(
160
181
  arguments: dict[str, Any],
161
182
  config: dict[str, Any],
162
183
  ) -> dict[str, Any]:
163
- from agent_wallet.openclaw_runtime import onboard_openclaw_user_wallet
164
-
165
- context = onboard_openclaw_user_wallet(
166
- user_id,
167
- backend=config.get("backend"),
168
- sign_only=config.get("signOnly"),
169
- network=config.get("network"),
170
- rpc_url=config.get("rpcUrl"),
171
- wdk_btc_service_url=config.get("wdkBtcServiceUrl"),
172
- wdk_btc_wallet_id=config.get("wdkBtcWalletId"),
173
- wdk_btc_account_index=config.get("wdkBtcAccountIndex"),
174
- wdk_evm_service_url=config.get("wdkEvmServiceUrl"),
175
- wdk_evm_wallet_id=config.get("wdkEvmWalletId"),
176
- wdk_evm_account_index=config.get("wdkEvmAccountIndex"),
177
- )
184
+ context = _build_runtime_context(user_id, config)
178
185
  result = await context.adapter.invoke(tool_name, arguments)
179
186
  return result.model_dump()
180
187
 
@@ -188,21 +195,7 @@ async def _run_issue_approval(
188
195
  mainnet_confirmed: bool = False,
189
196
  ttl_seconds: int | None = None,
190
197
  ) -> dict[str, Any]:
191
- from agent_wallet.openclaw_runtime import onboard_openclaw_user_wallet
192
-
193
- context = onboard_openclaw_user_wallet(
194
- user_id,
195
- backend=config.get("backend"),
196
- sign_only=config.get("signOnly"),
197
- network=config.get("network"),
198
- rpc_url=config.get("rpcUrl"),
199
- wdk_btc_service_url=config.get("wdkBtcServiceUrl"),
200
- wdk_btc_wallet_id=config.get("wdkBtcWalletId"),
201
- wdk_btc_account_index=config.get("wdkBtcAccountIndex"),
202
- wdk_evm_service_url=config.get("wdkEvmServiceUrl"),
203
- wdk_evm_wallet_id=config.get("wdkEvmWalletId"),
204
- wdk_evm_account_index=config.get("wdkEvmAccountIndex"),
205
- )
198
+ context = _build_runtime_context(user_id, config)
206
199
  token = context.issue_execute_approval(
207
200
  tool_name=tool_name,
208
201
  confirmation_summary=summary,
@@ -250,6 +243,69 @@ def _run_autonomous_permission(action: str, scope: str) -> dict[str, Any]:
250
243
  raise WalletBackendError("action must be approve, revoke, or status.")
251
244
 
252
245
 
246
+ def _error_payload_for_exception(exc: Exception) -> dict[str, Any]:
247
+ payload: dict[str, Any] = {"ok": False, "error": str(exc)}
248
+ if isinstance(exc, WalletBackendError):
249
+ if exc.code:
250
+ payload["code"] = exc.code
251
+ if exc.details is not None:
252
+ payload["details"] = exc.details
253
+ return payload
254
+
255
+
256
+ async def _run_read_worker_tool(context: Any, request: dict[str, Any]) -> dict[str, Any]:
257
+ tool_name = str(request.get("tool") or "").strip()
258
+ if tool_name not in READ_ONLY_WORKER_TOOLS:
259
+ raise WalletBackendError(
260
+ f"read-worker only allows read-only tools: {', '.join(sorted(READ_ONLY_WORKER_TOOLS))}."
261
+ )
262
+ arguments = request.get("arguments") or {}
263
+ if not isinstance(arguments, dict):
264
+ raise WalletBackendError("read-worker request arguments must be a JSON object.")
265
+ result = await context.adapter.invoke(tool_name, arguments)
266
+ return result.model_dump()
267
+
268
+
269
+ async def _serve_read_worker(user_id: str, config: dict[str, Any]) -> int:
270
+ context = _build_runtime_context(user_id, config, read_only=True)
271
+ while True:
272
+ line = sys.stdin.readline()
273
+ if not line:
274
+ break
275
+ text = line.strip()
276
+ if not text:
277
+ continue
278
+ request_id: str | None = None
279
+ try:
280
+ request = _load_json(text)
281
+ request_id = str(request.get("id") or "").strip() or None
282
+ if request.get("op") == "shutdown":
283
+ print(json.dumps({"id": request_id, "ok": True, "stopped": True}), flush=True)
284
+ break
285
+ payload = await _run_read_worker_tool(context, request)
286
+ print(
287
+ json.dumps(
288
+ {
289
+ "id": request_id,
290
+ "ok": True,
291
+ "payload": payload,
292
+ }
293
+ ),
294
+ flush=True,
295
+ )
296
+ except Exception as exc:
297
+ print(
298
+ json.dumps(
299
+ {
300
+ "id": request_id,
301
+ **_error_payload_for_exception(exc),
302
+ }
303
+ ),
304
+ flush=True,
305
+ )
306
+ return 0
307
+
308
+
253
309
  async def _run_btc_wallet_get(user_id: str, config: dict[str, Any]) -> dict[str, Any]:
254
310
  from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
255
311
 
@@ -522,6 +578,19 @@ def main() -> int:
522
578
  evm_lock_parser.add_argument("--user-id", required=True)
523
579
  evm_lock_parser.add_argument("--config-json", default="{}")
524
580
 
581
+ read_worker_parser = subparsers.add_parser("read-worker")
582
+ read_worker_parser.add_argument("--user-id", required=True)
583
+ read_worker_parser.add_argument("--config-json", default="{}")
584
+
585
+ subparsers.add_parser("boot-key-export")
586
+
587
+ boot_key_import_parser = subparsers.add_parser("boot-key-import")
588
+ boot_key_import_parser.add_argument(
589
+ "--key-stdin",
590
+ action="store_true",
591
+ help="Read the boot key to import from stdin.",
592
+ )
593
+
525
594
  args = parser.parse_args()
526
595
 
527
596
  try:
@@ -643,6 +712,19 @@ def main() -> int:
643
712
  )
644
713
  elif args.command == "evm-wallet-lock":
645
714
  payload = asyncio.run(_run_evm_wallet_lock(args.user_id, config))
715
+ elif args.command == "read-worker":
716
+ return asyncio.run(_serve_read_worker(args.user_id, config))
717
+ elif args.command == "boot-key-export":
718
+ from agent_wallet.boot_key_recovery import export_boot_key, export_warning
719
+
720
+ print(export_warning(), file=sys.stderr)
721
+ payload = {"boot_key": export_boot_key()}
722
+ elif args.command == "boot-key-import":
723
+ if not args.key_stdin:
724
+ raise WalletBackendError("boot-key-import requires --key-stdin.")
725
+ from agent_wallet.boot_key_recovery import import_boot_key
726
+
727
+ payload = import_boot_key(_read_stdin_secret("boot key"))
646
728
  else:
647
729
  payload = asyncio.run(
648
730
  _run_invoke(
@@ -662,13 +744,7 @@ def main() -> int:
662
744
  backend=str(locals().get("config", {}).get("backend", "") or ""),
663
745
  ok=False,
664
746
  )
665
- error_payload: dict[str, Any] = {"ok": False, "error": str(exc)}
666
- if isinstance(exc, WalletBackendError):
667
- if exc.code:
668
- error_payload["code"] = exc.code
669
- if exc.details is not None:
670
- error_payload["details"] = exc.details
671
- print(json.dumps(error_payload), file=sys.stderr)
747
+ print(json.dumps(_error_payload_for_exception(exc)), file=sys.stderr)
672
748
  return 1
673
749
 
674
750
  if getattr(args, "command", "") == "invoke":
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from typing import Any
7
7
 
8
8
  from agent_wallet.approval import issue_approval_token
9
+ from agent_wallet.boot_key_migration import migrate_boot_key_to_keystore
9
10
  from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
10
11
  from agent_wallet.config import normalize_btc_network, normalize_evm_network, settings
11
12
  from agent_wallet.evm_user_wallets import ensure_user_evm_wallet_ready
@@ -19,6 +20,24 @@ from agent_wallet.wallet_layer.base import AgentWalletBackend, WalletBackendErro
19
20
  from agent_wallet.wallet_layer.wdk_evm import WdkEvmLocalWalletBackend
20
21
  from agent_wallet.wallet_layer.wdk_btc import WdkBtcLocalWalletBackend
21
22
 
23
+ _boot_key_migration_done = False
24
+
25
+
26
+ def ensure_boot_key_migrated_once() -> None:
27
+ """Run the boot-key keystore migration at most once per process. Never raises.
28
+
29
+ Best-effort: a failure here must never block wallet onboarding — the legacy
30
+ plaintext sources still resolve via ``resolve_boot_key``.
31
+ """
32
+ global _boot_key_migration_done
33
+ if _boot_key_migration_done:
34
+ return
35
+ _boot_key_migration_done = True
36
+ try:
37
+ migrate_boot_key_to_keystore()
38
+ except Exception:
39
+ pass
40
+
22
41
 
23
42
  @dataclass(slots=True)
24
43
  class OpenClawWalletRuntimeContext:
@@ -87,6 +106,7 @@ def onboard_openclaw_user_wallet(
87
106
  *,
88
107
  backend: str | None = None,
89
108
  sign_only: bool | None = None,
109
+ read_only: bool = False,
90
110
  network: str | None = None,
91
111
  rpc_url: str | None = None,
92
112
  wdk_btc_service_url: str | None = None,
@@ -97,7 +117,11 @@ def onboard_openclaw_user_wallet(
97
117
  wdk_evm_account_index: int | None = None,
98
118
  ) -> OpenClawWalletRuntimeContext:
99
119
  """Provision and assemble a runtime-ready wallet context for one OpenClaw user."""
120
+ ensure_boot_key_migrated_once()
100
121
  backend_name = str(backend or settings.agent_wallet_backend).strip().lower()
122
+ effective_sign_only = True if read_only else (
123
+ settings.agent_wallet_sign_only if sign_only is None else sign_only
124
+ )
101
125
  if backend_name in {"wdk_btc_local", "wdk-btc-local", "btc_local", "btc-local"}:
102
126
  service_url = str(wdk_btc_service_url or settings.wdk_btc_service_url).strip()
103
127
  account_index = (
@@ -133,7 +157,7 @@ def onboard_openclaw_user_wallet(
133
157
  wallet_id=wallet_id,
134
158
  network=effective_network,
135
159
  account_index=account_index,
136
- sign_only=settings.agent_wallet_sign_only if sign_only is None else sign_only,
160
+ sign_only=effective_sign_only,
137
161
  address=str(address_payload.get("address") or "").strip() or None,
138
162
  )
139
163
  wallet_info = {
@@ -188,7 +212,7 @@ def onboard_openclaw_user_wallet(
188
212
  wallet_id=wallet_id,
189
213
  network=effective_network,
190
214
  account_index=account_index,
191
- sign_only=settings.agent_wallet_sign_only if sign_only is None else sign_only,
215
+ sign_only=effective_sign_only,
192
216
  address=resolved_address or None,
193
217
  )
194
218
  wallet_info = {
@@ -213,7 +237,8 @@ def onboard_openclaw_user_wallet(
213
237
 
214
238
  backend, wallet_info, created_now = create_openclaw_solana_backend(
215
239
  user_id,
216
- sign_only=sign_only,
240
+ sign_only=effective_sign_only,
241
+ read_only=read_only,
217
242
  network=network,
218
243
  rpc_url=rpc_url,
219
244
  )
@@ -483,3 +483,37 @@ async def fetch_prices(
483
483
  if not isinstance(data, dict):
484
484
  raise ProviderError("jupiter", "Unexpected price response from Jupiter.")
485
485
  return data
486
+
487
+
488
+ async def fetch_token_metadata(*, mints: list[str]) -> dict[str, dict[str, Any]]:
489
+ """Look up token symbol/name for a batch of mints via Jupiter's token
490
+ search API. Keyed by mint address; mints Jupiter doesn't index (very new
491
+ or illiquid tokens) are simply absent from the result rather than
492
+ raising, since this is display-only enrichment on top of the price data.
493
+ """
494
+ if not mints:
495
+ return {}
496
+ client = get_client()
497
+ params = {"query": ",".join(mints)}
498
+ response = await client.get(
499
+ f"{settings.jupiter_token_search_api_base_url.rstrip('/')}/search",
500
+ params=params,
501
+ headers=_headers(),
502
+ )
503
+ if response.status_code != 200:
504
+ raise ProviderError("jupiter", f"HTTP {response.status_code}: {response.text[:300]}")
505
+ data = response.json()
506
+ if not isinstance(data, list):
507
+ raise ProviderError("jupiter", "Unexpected token search response from Jupiter.")
508
+ result: dict[str, dict[str, Any]] = {}
509
+ for item in data:
510
+ if not isinstance(item, dict):
511
+ continue
512
+ mint = str(item.get("id") or "").strip()
513
+ if not mint:
514
+ continue
515
+ result[mint] = {
516
+ "symbol": item.get("symbol"),
517
+ "name": item.get("name"),
518
+ }
519
+ return result