@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.
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/CHANGELOG.md +51 -0
- package/VERSION +1 -1
- package/agent-wallet/.env.example +1 -0
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/config.py +9 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +95 -39
- package/agent-wallet/agent_wallet/openclaw_runtime.py +8 -3
- package/agent-wallet/agent_wallet/providers/jupiter.py +34 -0
- package/agent-wallet/agent_wallet/user_wallets.py +46 -3
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +34 -8
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/commands/wallet-sol.md +9 -15
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/README.md +9 -0
- package/codex/plugins/agent-wallet/server.py +353 -4
- package/codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md +29 -0
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -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.
|
|
5
|
+
"version": "0.1.58",
|
|
6
6
|
"contracts": {
|
|
7
7
|
"tools": [
|
|
8
8
|
"agentlayer_autonomous_approve",
|
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.
|
|
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.
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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(
|
|
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=
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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.
|
|
5
|
+
"version": "0.1.58",
|
|
6
6
|
"skills": ["skills/wallet-operator"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-wallet",
|
|
3
3
|
"displayName": "Agent Wallet",
|
|
4
|
-
"version": "0.1.
|
|
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
|
|
3
|
-
allowed-tools:
|
|
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
|
|
7
|
+
Show the connected Solana wallet portfolio directly in chat.
|
|
8
8
|
|
|
9
|
-
1. Call `
|
|
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.
|
|
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.
|
|
@@ -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(
|
|
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
|
-
|
|
1084
|
-
|
|
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.
|
package/package.json
CHANGED