@agentlayer.tech/wallet 0.1.55 → 0.1.58

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.55",
5
+ "version": "0.1.58",
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.55",
3
+ "version": "0.1.58",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.1.58 - 2026-07-01
6
+
7
+ - Resolved SPL token symbol/name in `get_wallet_portfolio` via a batched,
8
+ concurrent Jupiter token-search lookup, so `/wallet-sol` shows tickers
9
+ (e.g. `USDC`) instead of raw mint addresses. The mint/token_address is
10
+ still returned in full and rendered shortened next to the label rather
11
+ than dropped. Falls back gracefully (symbol=None) when a mint isn't
12
+ indexed or the lookup provider is unavailable.
13
+ - `agent-wallet/agent_wallet/providers/jupiter.py`
14
+ - `agent-wallet/agent_wallet/wallet_layer/solana.py`
15
+ - `claude-code/plugins/agent-wallet/commands/wallet-sol.md`
16
+ - `codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md`
17
+ - Dropped the `/wallet-sol` source-metadata footer line (`source`,
18
+ `token_discovery_source`, `pricing_source`, `pricing_errors`) from the
19
+ rendered chat output for a more compact report.
20
+ - `claude-code/plugins/agent-wallet/commands/wallet-sol.md`
21
+ - `codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md`
22
+
23
+ ## v0.1.57 - 2026-07-01
24
+
25
+ - Added a bundled Codex `wallet-sol` skill so Codex users can render the
26
+ connected Solana wallet portfolio in chat the same way Claude Code's
27
+ `/wallet-sol` command does.
28
+ - `codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md`
29
+ - `codex/plugins/agent-wallet/README.md`
30
+ - Fixed the resident read worker (used by `get_wallet_balance` /
31
+ `get_wallet_portfolio`, e.g. `/wallet-sol`) decrypting the wallet's private
32
+ key on every read-only cold start even though it never needed it. Read-only
33
+ onboarding now resolves the address from the existing plaintext wallet pin
34
+ file once a wallet has been provisioned, instead of unsealing secrets and
35
+ deriving a signer just to discard it.
36
+ - `agent-wallet/agent_wallet/user_wallets.py`
37
+ - Reduced `/wallet-sol` context payload by dropping the unused raw Jupiter
38
+ price blob (`price_raw`) from portfolio token entries.
39
+ - `agent-wallet/agent_wallet/wallet_layer/solana.py`
40
+ - Parallelized Jupiter price batch fetches in `get_portfolio` instead of
41
+ awaiting each 20-mint batch sequentially, speeding up portfolio lookups for
42
+ wallets with many SPL token accounts.
43
+ - `agent-wallet/agent_wallet/wallet_layer/solana.py`
44
+ - Added a background prewarm of the resident read worker at MCP server
45
+ startup so interpreter boot and onboarding overlap with the user issuing
46
+ the first read-only command instead of blocking it. Opt out with
47
+ `AGENT_WALLET_PREWARM_READ_WORKER=0`.
48
+ - `codex/plugins/agent-wallet/server.py`
49
+ - Added idle eviction for resident read workers left over from a stale
50
+ config (e.g. after switching Solana network or wallet backend), bounded by
51
+ `AGENT_WALLET_READ_WORKER_IDLE_SECONDS` (default 10 minutes), and closed
52
+ resident read workers on SIGTERM in addition to the existing `atexit`
53
+ cleanup, since Python does not run `atexit` hooks on signal termination.
54
+ - `codex/plugins/agent-wallet/server.py`
55
+
5
56
  ## v0.1.54 - 2026-06-29
6
57
 
7
58
  - Fixed Claude Code autonomous-mode slash command activation so
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.55
1
+ 0.1.58
@@ -43,6 +43,7 @@ SOLANA_AGENT_KEYPAIR_PATH=
43
43
  JUPITER_API_BASE_URL=https://lite-api.jup.ag/swap/v1
44
44
  JUPITER_ULTRA_API_BASE_URL=https://lite-api.jup.ag/ultra/v1
45
45
  JUPITER_PRICE_API_BASE_URL=https://lite-api.jup.ag/price/v3
46
+ JUPITER_TOKEN_SEARCH_API_BASE_URL=https://lite-api.jup.ag/tokens/v2
46
47
  JUPITER_API_KEY=
47
48
 
48
49
  # LI.FI cross-chain routing. API key is optional for basic read-only quote/status calls.
@@ -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.55"
5
+ __version__ = "0.1.58"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -48,6 +48,7 @@ class Settings(BaseSettings):
48
48
  jupiter_swap_v2_api_base_url: str = "https://api.jup.ag/swap/v2"
49
49
  jupiter_ultra_api_base_url: str = "https://lite-api.jup.ag/ultra/v1"
50
50
  jupiter_price_api_base_url: str = "https://lite-api.jup.ag/price/v3"
51
+ jupiter_token_search_api_base_url: str = "https://lite-api.jup.ag/tokens/v2"
51
52
  jupiter_api_key: str = ""
52
53
  lifi_api_base_url: str = "https://li.quest/v1"
53
54
  lifi_api_key: str = ""
@@ -74,6 +75,14 @@ class Settings(BaseSettings):
74
75
  settings = Settings()
75
76
 
76
77
 
78
+ def reload_settings() -> Settings:
79
+ """Reload settings from the current environment without changing object identity."""
80
+ refreshed = Settings()
81
+ for field_name in Settings.model_fields:
82
+ setattr(settings, field_name, getattr(refreshed, field_name))
83
+ return settings
84
+
85
+
77
86
  def normalize_solana_network(network: str | None) -> str:
78
87
  """Canonicalize supported Solana network names and reject test clusters."""
79
88
  normalized = str(network or "").strip().lower() or "mainnet"
@@ -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,10 @@ 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
+
525
585
  args = parser.parse_args()
526
586
 
527
587
  try:
@@ -643,6 +703,8 @@ def main() -> int:
643
703
  )
644
704
  elif args.command == "evm-wallet-lock":
645
705
  payload = asyncio.run(_run_evm_wallet_lock(args.user_id, config))
706
+ elif args.command == "read-worker":
707
+ return asyncio.run(_serve_read_worker(args.user_id, config))
646
708
  else:
647
709
  payload = asyncio.run(
648
710
  _run_invoke(
@@ -662,13 +724,7 @@ def main() -> int:
662
724
  backend=str(locals().get("config", {}).get("backend", "") or ""),
663
725
  ok=False,
664
726
  )
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)
727
+ print(json.dumps(_error_payload_for_exception(exc)), file=sys.stderr)
672
728
  return 1
673
729
 
674
730
  if getattr(args, "command", "") == "invoke":
@@ -87,6 +87,7 @@ def onboard_openclaw_user_wallet(
87
87
  *,
88
88
  backend: str | None = None,
89
89
  sign_only: bool | None = None,
90
+ read_only: bool = False,
90
91
  network: str | None = None,
91
92
  rpc_url: str | None = None,
92
93
  wdk_btc_service_url: str | None = None,
@@ -98,6 +99,9 @@ def onboard_openclaw_user_wallet(
98
99
  ) -> OpenClawWalletRuntimeContext:
99
100
  """Provision and assemble a runtime-ready wallet context for one OpenClaw user."""
100
101
  backend_name = str(backend or settings.agent_wallet_backend).strip().lower()
102
+ effective_sign_only = True if read_only else (
103
+ settings.agent_wallet_sign_only if sign_only is None else sign_only
104
+ )
101
105
  if backend_name in {"wdk_btc_local", "wdk-btc-local", "btc_local", "btc-local"}:
102
106
  service_url = str(wdk_btc_service_url or settings.wdk_btc_service_url).strip()
103
107
  account_index = (
@@ -133,7 +137,7 @@ def onboard_openclaw_user_wallet(
133
137
  wallet_id=wallet_id,
134
138
  network=effective_network,
135
139
  account_index=account_index,
136
- sign_only=settings.agent_wallet_sign_only if sign_only is None else sign_only,
140
+ sign_only=effective_sign_only,
137
141
  address=str(address_payload.get("address") or "").strip() or None,
138
142
  )
139
143
  wallet_info = {
@@ -188,7 +192,7 @@ def onboard_openclaw_user_wallet(
188
192
  wallet_id=wallet_id,
189
193
  network=effective_network,
190
194
  account_index=account_index,
191
- sign_only=settings.agent_wallet_sign_only if sign_only is None else sign_only,
195
+ sign_only=effective_sign_only,
192
196
  address=resolved_address or None,
193
197
  )
194
198
  wallet_info = {
@@ -213,7 +217,8 @@ def onboard_openclaw_user_wallet(
213
217
 
214
218
  backend, wallet_info, created_now = create_openclaw_solana_backend(
215
219
  user_id,
216
- sign_only=sign_only,
220
+ sign_only=effective_sign_only,
221
+ read_only=read_only,
217
222
  network=network,
218
223
  rpc_url=rpc_url,
219
224
  )
@@ -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
@@ -147,10 +147,31 @@ def _load_user_wallet_secret_material(
147
147
  )
148
148
 
149
149
 
150
- def ensure_user_solana_wallet(user_id: str, network: str | None = None) -> dict[str, str]:
150
+ def ensure_user_solana_wallet(
151
+ user_id: str,
152
+ network: str | None = None,
153
+ *,
154
+ read_only: bool = False,
155
+ ) -> dict[str, str]:
151
156
  """Provision a per-user Solana wallet if it does not exist yet."""
152
157
  effective_network = _resolve_effective_network(network)
153
158
  path = resolve_user_wallet_path(user_id, network=effective_network)
159
+ if read_only and path.exists():
160
+ # Read-only callers only need the address. The pin file already stores
161
+ # it in plaintext, so skip decrypting the wallet secret material and
162
+ # deriving the signer entirely rather than paying for a KDF unseal +
163
+ # keypair derivation just to read (and immediately discard) a private
164
+ # key. Falls through to the full decrypt path below if no pin exists
165
+ # yet (e.g. a wallet file predating pin files).
166
+ pin = load_wallet_pin(path)
167
+ if pin is not None and pin["network"] == effective_network:
168
+ return {
169
+ "user_id": user_id,
170
+ "address": pin["address"],
171
+ "path": str(path),
172
+ "storage_format": "encrypted",
173
+ "key_scope": "pinned-address-only",
174
+ }
154
175
  if path.exists():
155
176
  secret_material, storage_format, key_scope = _load_user_wallet_secret_material(
156
177
  path,
@@ -362,6 +383,7 @@ def create_openclaw_solana_backend(
362
383
  user_id: str,
363
384
  *,
364
385
  sign_only: bool | None = None,
386
+ read_only: bool = False,
365
387
  network: str | None = None,
366
388
  rpc_url: str | None = None,
367
389
  ) -> tuple[SolanaWalletBackend, dict[str, str], bool]:
@@ -396,6 +418,11 @@ def create_openclaw_solana_backend(
396
418
  raise WalletBackendError(
397
419
  "Configured Solana publicKey does not match the signer derived from keypairPath/runtime secret."
398
420
  )
421
+ effective_sign_only = True if read_only else (
422
+ settings.agent_wallet_sign_only if sign_only is None else sign_only
423
+ )
424
+ if read_only:
425
+ signer = None
399
426
 
400
427
  rpc_config = resolve_runtime_solana_rpc_config(
401
428
  effective_network,
@@ -411,7 +438,7 @@ def create_openclaw_solana_backend(
411
438
  network=effective_network,
412
439
  signer=signer,
413
440
  address=resolved_address or None,
414
- sign_only=settings.agent_wallet_sign_only if sign_only is None else sign_only,
441
+ sign_only=effective_sign_only,
415
442
  rpc_provider_mode=str(rpc_config["mode"]),
416
443
  rpc_provider=str(rpc_config["provider"]),
417
444
  rpc_transport=str(rpc_config["transport"]),
@@ -429,11 +456,27 @@ def create_openclaw_solana_backend(
429
456
 
430
457
  wallet_path = resolve_user_wallet_path(user_id, network=effective_network)
431
458
  created_now = not wallet_path.exists()
459
+ wallet_info = ensure_user_solana_wallet(user_id, network=effective_network, read_only=read_only)
460
+ if read_only:
461
+ backend = SolanaWalletBackend(
462
+ rpc_url=rpc_config["rpc_urls"],
463
+ commitment=settings.solana_commitment,
464
+ network=effective_network,
465
+ signer=None,
466
+ address=wallet_info["address"] or None,
467
+ sign_only=effective_sign_only,
468
+ rpc_provider_mode=str(rpc_config["mode"]),
469
+ rpc_provider=str(rpc_config["provider"]),
470
+ rpc_transport=str(rpc_config["transport"]),
471
+ swap_provider=str(swap_config["provider"]),
472
+ swap_transport=str(swap_config["transport"]),
473
+ )
474
+ return backend, wallet_info, created_now
475
+
432
476
  backend = create_wallet_backend_for_user(
433
477
  user_id,
434
478
  sign_only=sign_only,
435
479
  network=effective_network,
436
480
  rpc_url=rpc_url,
437
481
  )
438
- wallet_info = ensure_user_solana_wallet(user_id, network=effective_network)
439
482
  return backend, wallet_info, created_now
@@ -339,17 +339,37 @@ class SolanaWalletBackend(AgentWalletBackend):
339
339
 
340
340
  price_data_by_mint: dict[str, dict[str, Any]] = {}
341
341
  price_errors: list[str] = []
342
- for index in range(0, len(mints), 20):
343
- batch = mints[index : index + 20]
344
- try:
345
- price_data = await jupiter.fetch_prices(mints=batch)
346
- except ProviderError as exc:
347
- price_errors.append(str(exc))
342
+ token_metadata_by_mint: dict[str, dict[str, Any]] = {}
343
+ token_metadata_errors: list[str] = []
344
+ price_batches = [mints[index : index + 20] for index in range(0, len(mints), 20)]
345
+ # Independent HTTP calls against a shared async client, so fetch every
346
+ # price batch AND every token symbol/name lookup batch concurrently
347
+ # instead of paying sequential round trips for wallets with many SPL
348
+ # token accounts.
349
+ all_results = await asyncio.gather(
350
+ *(jupiter.fetch_prices(mints=batch) for batch in price_batches),
351
+ *(jupiter.fetch_token_metadata(mints=batch) for batch in price_batches),
352
+ return_exceptions=True,
353
+ )
354
+ batch_results = all_results[: len(price_batches)]
355
+ metadata_batch_results = all_results[len(price_batches) :]
356
+ for batch, result in zip(price_batches, batch_results):
357
+ if isinstance(result, ProviderError):
358
+ price_errors.append(str(result))
348
359
  continue
360
+ if isinstance(result, BaseException):
361
+ raise result
349
362
  for mint in batch:
350
- entry = _jupiter_price_entry(price_data, mint)
363
+ entry = _jupiter_price_entry(result, mint)
351
364
  if entry is not None:
352
365
  price_data_by_mint[mint] = entry
366
+ for result in metadata_batch_results:
367
+ if isinstance(result, ProviderError):
368
+ token_metadata_errors.append(str(result))
369
+ continue
370
+ if isinstance(result, BaseException):
371
+ raise result
372
+ token_metadata_by_mint.update(result)
353
373
 
354
374
  native_price = _jupiter_usd_price(price_data_by_mint.get(NATIVE_SOL_MINT))
355
375
  native_amount = _coerce_decimal(native_balance.get("balance_native"))
@@ -379,13 +399,15 @@ class SolanaWalletBackend(AgentWalletBackend):
379
399
  if value is not None:
380
400
  total_value += value
381
401
  priced_asset_count += 1
402
+ metadata = token_metadata_by_mint.get(mint) or {}
382
403
  enriched_tokens.append(
383
404
  {
384
405
  **token,
406
+ "symbol": metadata.get("symbol"),
407
+ "name": metadata.get("name"),
385
408
  "price_usd": str(price) if price is not None else None,
386
409
  "value_usd": _format_decimal(value),
387
410
  "pricing_source": "jupiter-price" if price is not None else None,
388
- "price_raw": price_data_by_mint.get(mint),
389
411
  }
390
412
  )
391
413
 
@@ -410,6 +432,8 @@ class SolanaWalletBackend(AgentWalletBackend):
410
432
  {
411
433
  "asset_type": "spl-token",
412
434
  "mint": token.get("mint"),
435
+ "symbol": token.get("symbol"),
436
+ "name": token.get("name"),
413
437
  "token_account": token.get("token_account"),
414
438
  "amount_raw": token.get("amount_raw"),
415
439
  "amount_ui": token.get("amount_ui"),
@@ -442,6 +466,8 @@ class SolanaWalletBackend(AgentWalletBackend):
442
466
  "total_value_usd": formatted_total_value,
443
467
  "pricing_source": "jupiter-price" if price_data_by_mint else None,
444
468
  "pricing_errors": price_errors,
469
+ "token_metadata_source": "jupiter-token-search" if token_metadata_by_mint else None,
470
+ "token_metadata_errors": token_metadata_errors,
445
471
  "token_discovery_source": "solana-rpc",
446
472
  "source": "solana-rpc+jupiter-price",
447
473
  }
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Plugin-friendly wallet backend for OpenClaw agents with safe wallet tools and runtime instructions across Solana, local BTC, and local EVM.",
5
- "version": "0.1.55",
5
+ "version": "0.1.58",
6
6
  "skills": ["skills/wallet-operator"],
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.55"
7
+ version = "0.1.58"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
3
  "displayName": "Agent Wallet",
4
- "version": "0.1.55",
4
+ "version": "0.1.58",
5
5
  "description": "Claude Code bridge for the existing AgentLayer wallet runtime. Connects to Solana, Bitcoin, and EVM wallets without creating a new one.",
6
6
  "author": {
7
7
  "name": "AgentLayer"
@@ -1,17 +1,15 @@
1
1
  ---
2
- description: Show the connected Solana wallet overview directly in chat.
3
- allowed-tools: mcp__agent_wallet__get_wallet_overview
2
+ description: Show the connected Solana wallet portfolio directly in chat.
3
+ allowed-tools: mcp__agent_wallet__get_wallet_portfolio
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
7
- Show the connected Solana wallet overview directly in chat.
7
+ Show the connected Solana wallet portfolio directly in chat.
8
8
 
9
- 1. Call `get_wallet_overview` with:
9
+ 1. Call `get_wallet_portfolio` with:
10
10
 
11
11
  ```json
12
- {
13
- "backend": "solana"
14
- }
12
+ {}
15
13
  ```
16
14
 
17
15
  2. Format the response as a compact wallet report:
@@ -21,16 +19,12 @@ Show the connected Solana wallet overview directly in chat.
21
19
  - render a Markdown table with columns: `Asset | Type | Amount | USD Value`
22
20
  - use `assets` when present
23
21
  - for each asset row, prefer:
24
- - asset label: `symbol`, then `mint`, then `token_address`, then `asset_type`
22
+ - asset label: `symbol`, then `name`, then `mint`, then `token_address`, then `asset_type`
23
+ - when the label came from `symbol`/`name` (not the mint/token_address itself) and a `mint` or `token_address` is present, append it shortened in parentheses next to the label as `first6…last4` (e.g. `USDC (EPjFWd…TDt1v)`) — keep the contract visible but compact, never show it twice
25
24
  - amount: `amount_ui`, then `balance_ui`, then `balance_native`, then `amount_raw`
26
25
  - usd value: `value_usd`, then `balance_usd`
27
26
  - omit zero-value rows only when both the amount and USD value are clearly zero
28
27
  - if no asset rows are available, still report the native balance summary in prose
28
+ - do not include source metadata lines or footer fields such as `source`, `token_discovery_source`, `pricing_source`, or `pricing_errors`
29
29
 
30
- 3. After the table, add one short metadata line with any available sources:
31
-
32
- - `source`
33
- - `token_discovery_source`
34
- - `pricing_source`
35
-
36
- 4. Do not suggest transfers, swaps, or other write actions unless the user explicitly asks for them.
30
+ 3. Do not suggest transfers, swaps, or other write actions unless the user explicitly asks for them.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
- "version": "0.1.55",
3
+ "version": "0.1.58",
4
4
  "description": "Codex plugin bridge for the AgentLayer wallet runtime.",
5
5
  "author": {
6
6
  "name": "AgentLayer"
@@ -21,6 +21,8 @@ Primary design rules:
21
21
  - session wallet selection with `set_wallet_backend`
22
22
  - EVM network selection with `set_evm_network`
23
23
  - auto-managed approval binding for `preview -> execute` write flows
24
+ - bundled Codex skills, including `wallet-sol` for showing the Solana wallet
25
+ portfolio directly in chat
24
26
 
25
27
  ## Runtime requirements
26
28
 
@@ -28,6 +30,13 @@ Primary design rules:
28
30
  - keep the local wallet files and `~/.openclaw/sealed_keys.json` in place
29
31
  - use `wallet codex install --yes` to install this plugin into Codex
30
32
 
33
+ ## Bundled skill
34
+
35
+ After `wallet codex install --yes` and a Codex restart, the plugin ships a
36
+ bundled `wallet-sol` skill. In Codex you can invoke it from the slash menu or
37
+ explicitly as `$wallet-sol` to render the connected Solana wallet portfolio as a
38
+ compact chat table.
39
+
31
40
  ## Path resolution
32
41
 
33
42
  The bridge resolves the wallet runtime from:
@@ -2,12 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import atexit
5
6
  import asyncio
6
7
  import copy
7
8
  import base64
8
9
  import hashlib
9
10
  import json
10
11
  import os
12
+ import selectors
13
+ import signal
11
14
  import subprocess
12
15
  import sys
13
16
  import threading
@@ -75,15 +78,21 @@ APPROVAL_CONTEXT_MISSING_MESSAGE = (
75
78
  "operation again, wait for explicit user confirmation, then retry execute. Do not ask the "
76
79
  "user for a manual approval token."
77
80
  )
81
+ RESIDENT_READ_ONLY_TOOLS = {
82
+ "get_wallet_balance",
83
+ "get_wallet_portfolio",
84
+ }
78
85
 
79
86
  selected_wallet_backend: str | None = None
80
87
  selected_solana_network: str | None = None
81
88
  selected_evm_network: str | None = None
82
89
  selected_btc_network: str | None = None
83
90
  approval_preview_cache: dict[str, dict[str, Any]] = {}
91
+ resident_read_workers: dict[str, "_ResidentReadWorker"] = {}
84
92
  # Guards approval_preview_cache against races once wallet calls run concurrently
85
93
  # via asyncio.to_thread. Reentrant so prune helpers can be nested under writers.
86
94
  _approval_cache_lock = threading.RLock()
95
+ _resident_worker_lock = threading.RLock()
87
96
 
88
97
 
89
98
  class WalletCliError(RuntimeError):
@@ -93,6 +102,10 @@ class WalletCliError(RuntimeError):
93
102
  self.details = details or {}
94
103
 
95
104
 
105
+ class ResidentReadWorkerTransportError(RuntimeError):
106
+ """Raised when the long-lived read worker transport fails."""
107
+
108
+
96
109
  def _plugin_root() -> Path:
97
110
  return Path(__file__).resolve().parent
98
111
 
@@ -625,6 +638,311 @@ def _invoke_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, An
625
638
  )
626
639
 
627
640
 
641
+ class _ResidentReadWorker:
642
+ def __init__(self, *, user_id: str, config: dict[str, Any]):
643
+ self.user_id = user_id
644
+ self.config = copy.deepcopy(config)
645
+ self.package_root = _resolve_package_root()
646
+ self._process: subprocess.Popen[str] | None = None
647
+ self._lock = threading.RLock()
648
+ self._request_id = 0
649
+ self._stderr_lines: list[str] = []
650
+ self._stderr_thread: threading.Thread | None = None
651
+ self._last_used = time.monotonic()
652
+
653
+ def touch(self) -> None:
654
+ self._last_used = time.monotonic()
655
+
656
+ def idle_seconds(self) -> float:
657
+ return time.monotonic() - self._last_used
658
+
659
+ def _command(self) -> list[str]:
660
+ return [
661
+ _python_bin(self.package_root),
662
+ "-m",
663
+ "agent_wallet.openclaw_cli",
664
+ "read-worker",
665
+ "--user-id",
666
+ self.user_id,
667
+ "--config-json",
668
+ json.dumps(self.config),
669
+ ]
670
+
671
+ def _drain_stderr(self) -> None:
672
+ process = self._process
673
+ if process is None or process.stderr is None:
674
+ return
675
+ try:
676
+ for raw_line in process.stderr:
677
+ line = raw_line.strip()
678
+ if not line:
679
+ continue
680
+ with self._lock:
681
+ self._stderr_lines.append(line)
682
+ if len(self._stderr_lines) > 20:
683
+ self._stderr_lines = self._stderr_lines[-20:]
684
+ except Exception:
685
+ return
686
+
687
+ def _stderr_summary(self) -> str:
688
+ with self._lock:
689
+ if not self._stderr_lines:
690
+ return ""
691
+ return " | ".join(self._stderr_lines[-5:])
692
+
693
+ def _ensure_started(self) -> subprocess.Popen[str]:
694
+ with self._lock:
695
+ process = self._process
696
+ if process is not None and process.poll() is None:
697
+ return process
698
+ try:
699
+ process = subprocess.Popen(
700
+ self._command(),
701
+ cwd=str(self.package_root),
702
+ env=_cli_env(self.package_root),
703
+ text=True,
704
+ stdin=subprocess.PIPE,
705
+ stdout=subprocess.PIPE,
706
+ stderr=subprocess.PIPE,
707
+ bufsize=1,
708
+ )
709
+ except Exception as exc:
710
+ raise ResidentReadWorkerTransportError(
711
+ f"Could not start resident read worker: {exc}"
712
+ ) from exc
713
+ self._process = process
714
+ self._stderr_lines = []
715
+ self._stderr_thread = threading.Thread(
716
+ target=self._drain_stderr,
717
+ name="agent-wallet-read-worker-stderr",
718
+ daemon=True,
719
+ )
720
+ self._stderr_thread.start()
721
+ return process
722
+
723
+ def warm(self) -> None:
724
+ """Spawn the worker eagerly, without waiting for a request/response.
725
+
726
+ Lets the interpreter boot, module imports, and read-only onboarding
727
+ happen off the critical path of the first real read-only tool call.
728
+ """
729
+ try:
730
+ self._ensure_started()
731
+ except ResidentReadWorkerTransportError:
732
+ pass
733
+
734
+ def close(self) -> None:
735
+ with self._lock:
736
+ process = self._process
737
+ self._process = None
738
+ if process is None:
739
+ return
740
+ try:
741
+ if process.poll() is None and process.stdin is not None:
742
+ process.stdin.write(json.dumps({"op": "shutdown"}) + "\n")
743
+ process.stdin.flush()
744
+ except Exception:
745
+ pass
746
+ try:
747
+ process.wait(timeout=1)
748
+ except Exception:
749
+ try:
750
+ process.terminate()
751
+ process.wait(timeout=1)
752
+ except Exception:
753
+ try:
754
+ process.kill()
755
+ except Exception:
756
+ pass
757
+
758
+ def invoke(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
759
+ with self._lock:
760
+ self.touch()
761
+ process = self._ensure_started()
762
+ if process.stdin is None or process.stdout is None:
763
+ self.close()
764
+ raise ResidentReadWorkerTransportError("Resident read worker stdio is unavailable.")
765
+ self._request_id += 1
766
+ request_id = str(self._request_id)
767
+ request = {
768
+ "id": request_id,
769
+ "tool": tool_name,
770
+ "arguments": arguments,
771
+ }
772
+ try:
773
+ process.stdin.write(json.dumps(request) + "\n")
774
+ process.stdin.flush()
775
+ except Exception as exc:
776
+ self.close()
777
+ raise ResidentReadWorkerTransportError(
778
+ f"Could not write to resident read worker: {exc}"
779
+ ) from exc
780
+
781
+ selector = selectors.DefaultSelector()
782
+ selector.register(process.stdout, selectors.EVENT_READ)
783
+ events = selector.select(timeout=_cli_timeout_seconds())
784
+ selector.close()
785
+ if not events:
786
+ self.close()
787
+ raise ResidentReadWorkerTransportError(
788
+ f"Resident read worker timed out after {_cli_timeout_seconds():g}s."
789
+ )
790
+ response_line = process.stdout.readline()
791
+ if not response_line:
792
+ self.close()
793
+ stderr_summary = self._stderr_summary()
794
+ detail = f" Worker stderr: {stderr_summary}" if stderr_summary else ""
795
+ raise ResidentReadWorkerTransportError(
796
+ "Resident read worker exited without a response." + detail
797
+ )
798
+ try:
799
+ response = json.loads(response_line)
800
+ except json.JSONDecodeError as exc:
801
+ self.close()
802
+ raise ResidentReadWorkerTransportError(
803
+ f"Resident read worker returned invalid JSON: {exc}"
804
+ ) from exc
805
+ if str(response.get("id") or "") != request_id:
806
+ self.close()
807
+ raise ResidentReadWorkerTransportError(
808
+ "Resident read worker response id did not match the request."
809
+ )
810
+ if response.get("ok") is False:
811
+ raise WalletCliError(
812
+ str(response.get("error") or "resident read worker failed."),
813
+ code=str(response.get("code") or ""),
814
+ details=response.get("details")
815
+ if isinstance(response.get("details"), dict)
816
+ else {},
817
+ )
818
+ payload = response.get("payload")
819
+ if not isinstance(payload, dict):
820
+ raise ResidentReadWorkerTransportError(
821
+ "Resident read worker returned a non-object payload."
822
+ )
823
+ return payload
824
+
825
+
826
+ def _resident_worker_cache_key(user_id: str, config: dict[str, Any]) -> str:
827
+ return _canonical_json_text(
828
+ {
829
+ "user_id": user_id,
830
+ "config": config,
831
+ }
832
+ )
833
+
834
+
835
+ def _resident_worker_idle_seconds() -> float:
836
+ """Idle threshold (seconds) after which an unused resident worker for a
837
+ config that is no longer the active one gets reaped. Falls back to 10
838
+ minutes on bad values."""
839
+ raw = os.getenv("AGENT_WALLET_READ_WORKER_IDLE_SECONDS", "600")
840
+ try:
841
+ value = float(raw)
842
+ except (TypeError, ValueError):
843
+ return 600.0
844
+ return value if value > 0 else 600.0
845
+
846
+
847
+ def _evict_idle_resident_read_workers(*, keep_key: str) -> None:
848
+ """Close resident workers other than `keep_key` that have been idle past
849
+ the configured threshold.
850
+
851
+ Each distinct (user_id, config) pair (e.g. switching Solana network or
852
+ wallet backend mid-session) keeps its own resident subprocess alive
853
+ indefinitely otherwise, since nothing previously evicted them. This
854
+ bounds that growth without adding a background timer thread: eviction
855
+ piggybacks on the next lookup instead.
856
+ """
857
+ idle_limit = _resident_worker_idle_seconds()
858
+ with _resident_worker_lock:
859
+ stale_keys = [
860
+ key
861
+ for key, worker in resident_read_workers.items()
862
+ if key != keep_key and worker.idle_seconds() > idle_limit
863
+ ]
864
+ stale_workers = [resident_read_workers.pop(key) for key in stale_keys]
865
+ for worker in stale_workers:
866
+ worker.close()
867
+
868
+
869
+ def _resident_read_worker_for_config(user_id: str, config: dict[str, Any]) -> _ResidentReadWorker:
870
+ key = _resident_worker_cache_key(user_id, config)
871
+ _evict_idle_resident_read_workers(keep_key=key)
872
+ with _resident_worker_lock:
873
+ worker = resident_read_workers.get(key)
874
+ if worker is None:
875
+ worker = _ResidentReadWorker(user_id=user_id, config=config)
876
+ resident_read_workers[key] = worker
877
+ return worker
878
+
879
+
880
+ def _shutdown_resident_read_workers() -> None:
881
+ with _resident_worker_lock:
882
+ workers = list(resident_read_workers.values())
883
+ resident_read_workers.clear()
884
+ for worker in workers:
885
+ worker.close()
886
+
887
+
888
+ atexit.register(_shutdown_resident_read_workers)
889
+
890
+
891
+ def _handle_termination_signal(signum: int, frame: Any) -> None:
892
+ """Close resident read worker subprocesses on SIGTERM, then terminate
893
+ normally.
894
+
895
+ atexit handlers only run on normal interpreter shutdown; Python's
896
+ default SIGTERM disposition terminates the process immediately without
897
+ unwinding to atexit. Hosts that gracefully stop this MCP server (e.g.
898
+ closing a Claude Code / Codex session) send SIGTERM, so without this the
899
+ resident worker subprocesses leaked past every normal shutdown, not just
900
+ an abrupt kill -9. (SIGINT is not handled here: Python's default handler
901
+ raises KeyboardInterrupt, which unwinds to a normal interpreter
902
+ shutdown and already triggers the atexit hook above.)
903
+ """
904
+ _shutdown_resident_read_workers()
905
+ signal.signal(signum, signal.SIG_DFL)
906
+ os.kill(os.getpid(), signum)
907
+
908
+
909
+ def _install_termination_signal_handlers() -> None:
910
+ try:
911
+ signal.signal(signal.SIGTERM, _handle_termination_signal)
912
+ except (ValueError, OSError):
913
+ # e.g. signal.signal() called outside the main thread.
914
+ pass
915
+
916
+
917
+ def _prewarm_resident_read_worker() -> None:
918
+ """Best-effort background warm-up of the default resident read worker.
919
+
920
+ server.py is a persistent stdio MCP process kept alive for the whole
921
+ host session, but the resident worker itself only used to spawn lazily
922
+ on the first read-only tool call (get_wallet_balance /
923
+ get_wallet_portfolio, e.g. /wallet-sol), putting interpreter boot +
924
+ onboarding on the critical path of that first call. Kick it off in a
925
+ daemon thread instead so it overlaps with the user issuing the command.
926
+ Failures here are silently ignored: the lazy path in
927
+ _invoke_read_tool_blocking remains the source of truth and will retry.
928
+ """
929
+ if os.getenv("AGENT_WALLET_PREWARM_READ_WORKER", "1").strip().lower() in {"0", "false", "no"}:
930
+ return
931
+
932
+ def _run() -> None:
933
+ try:
934
+ config = _base_config({}, tool_name="get_wallet_portfolio")
935
+ _resident_read_worker_for_config(_user_id(), config).warm()
936
+ except Exception:
937
+ pass
938
+
939
+ threading.Thread(
940
+ target=_run,
941
+ name="agent-wallet-read-worker-prewarm",
942
+ daemon=True,
943
+ ).start()
944
+
945
+
628
946
  def _issue_approval_token(
629
947
  tool_name: str,
630
948
  config: dict[str, Any],
@@ -654,6 +972,15 @@ def _issue_approval_token(
654
972
  return token
655
973
 
656
974
 
975
+ def _invoke_resident_read_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
976
+ worker = _resident_read_worker_for_config(_user_id(), config)
977
+ try:
978
+ return worker.invoke(tool_name, arguments)
979
+ except ResidentReadWorkerTransportError:
980
+ worker.close()
981
+ raise
982
+
983
+
657
984
  def _is_solana_swap_intent_execute(params: dict[str, Any]) -> bool:
658
985
  return str(params.get("mode") or "") == "intent_execute"
659
986
 
@@ -1033,7 +1360,9 @@ async def _handle_get_wallet_overview(params: dict[str, Any]) -> dict[str, Any]:
1033
1360
  if params.get("address") is not None:
1034
1361
  effective_params["address"] = params.get("address")
1035
1362
 
1036
- payload = await asyncio.to_thread(_invoke_tool, "get_wallet_balance", effective_params, config)
1363
+ payload = await asyncio.to_thread(
1364
+ _invoke_read_tool_blocking, "get_wallet_balance", effective_params, config
1365
+ )
1037
1366
  if payload.get("ok") is False:
1038
1367
  raise RuntimeError(str(payload.get("error") or "get_wallet_overview failed"))
1039
1368
  data = payload.get("data", {})
@@ -1072,6 +1401,19 @@ def _invoke_wallet_tool_blocking(
1072
1401
  return payload
1073
1402
 
1074
1403
 
1404
+ def _invoke_read_tool_blocking(
1405
+ tool_name: str,
1406
+ effective_params: dict[str, Any],
1407
+ config: dict[str, Any],
1408
+ ) -> dict[str, Any]:
1409
+ if tool_name not in RESIDENT_READ_ONLY_TOOLS:
1410
+ return _invoke_tool(tool_name, effective_params, config)
1411
+ try:
1412
+ return _invoke_resident_read_tool(tool_name, effective_params, config)
1413
+ except ResidentReadWorkerTransportError:
1414
+ return _invoke_tool(tool_name, effective_params, config)
1415
+
1416
+
1075
1417
  async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
1076
1418
  config = _base_config(params, tool_name=tool_name)
1077
1419
  backend = _normalize_wallet_backend(config.get("backend"))
@@ -1080,9 +1422,14 @@ async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[st
1080
1422
  config["network"] = selected_evm_network
1081
1423
 
1082
1424
  effective_params = dict(params)
1083
- payload = await asyncio.to_thread(
1084
- _invoke_wallet_tool_blocking, tool_name, config, effective_params
1085
- )
1425
+ if tool_name in RESIDENT_READ_ONLY_TOOLS:
1426
+ payload = await asyncio.to_thread(
1427
+ _invoke_read_tool_blocking, tool_name, effective_params, config
1428
+ )
1429
+ else:
1430
+ payload = await asyncio.to_thread(
1431
+ _invoke_wallet_tool_blocking, tool_name, config, effective_params
1432
+ )
1086
1433
 
1087
1434
  if payload.get("ok") is False:
1088
1435
  raise RuntimeError(str(payload.get("error") or f"{tool_name} failed"))
@@ -1173,6 +1520,8 @@ def build_server():
1173
1520
 
1174
1521
 
1175
1522
  def main() -> None:
1523
+ _install_termination_signal_handlers()
1524
+ _prewarm_resident_read_worker()
1176
1525
  build_server().run(show_banner=False)
1177
1526
 
1178
1527
 
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: "wallet-sol"
3
+ description: "Show the current Solana wallet portfolio from the local AgentLayer wallet in a compact chat table. Use when the user asks for /wallet-sol or wants the Solana wallet shown directly in chat."
4
+ ---
5
+
6
+ # Solana Wallet Portfolio
7
+
8
+ Use the local AgentLayer wallet MCP tools only.
9
+
10
+ Workflow:
11
+
12
+ 1. Call `set_wallet_backend` with `backend=solana`.
13
+ 2. Call `get_wallet_portfolio` with no address override.
14
+ 3. Render the result directly in chat in this shape:
15
+ - title: `Solana Wallet Portfolio`
16
+ - bullets for `Chain`, `Network`, `Address`, and `Total Value (USD)` when available
17
+ - one compact Markdown table with columns: `Asset | Type | Amount | USD Value`
18
+
19
+ Formatting rules:
20
+
21
+ - Use `assets` when present.
22
+ - For the asset label, prefer `symbol`, then `name`, then `mint`, then `token_address`, then `asset_type`.
23
+ - When the label came from `symbol`/`name` (not the mint/token_address itself) and a `mint` or `token_address` is present, append it shortened in parentheses next to the label as `first6…last4` (e.g. `USDC (EPjFWd…TDt1v)`) — keep the contract visible but compact, never show it twice.
24
+ - For the asset type, use `asset_type` when present; otherwise infer `native` for the native asset and `token` for the rest.
25
+ - For the amount, prefer `amount_ui`, then `balance_ui`, then `balance_native`, then `amount_raw`.
26
+ - For the USD value, prefer `value_usd`, then `balance_usd`.
27
+ - Do not include source metadata lines or extra footer fields such as `source`, `token_discovery_source`, `pricing_source`, or `pricing_errors`.
28
+ - Keep the response concise. Do not add strategy suggestions, swap ideas, or write actions unless the user explicitly asks for them.
29
+ - If the wallet tool fails, surface the tool error plainly and stop.
@@ -1,5 +1,5 @@
1
1
  name: agent-wallet
2
- version: 0.1.55
2
+ version: 0.1.58
3
3
  description: Thin Hermes Agent bridge to the existing AgentLayer/OpenClaw wallet backend
4
4
  provides_tools:
5
5
  - agent_wallet_tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.55",
3
+ "version": "0.1.58",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-btc-wallet",
3
- "version": "0.1.55",
3
+ "version": "0.1.58",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate BTC-only wallet service built on Tether WDK.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-evm-wallet",
3
- "version": "0.1.55",
3
+ "version": "0.1.58",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate EVM wallet service built on Tether WDK.",