@agentlayer.tech/wallet 0.1.53 → 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 +63 -0
- package/README.md +14 -12
- 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/README.md +9 -3
- package/claude-code/plugins/agent-wallet/commands/agentlayer-autonomous-approve.md +17 -2
- package/claude-code/plugins/agent-wallet/commands/agentlayer-autonomous-revoke.md +1 -0
- package/claude-code/plugins/agent-wallet/commands/wallet-base.md +37 -0
- package/claude-code/plugins/agent-wallet/commands/wallet-ethereum.md +37 -0
- package/claude-code/plugins/agent-wallet/commands/wallet-evm.md +38 -0
- package/claude-code/plugins/agent-wallet/commands/wallet-sol.md +30 -0
- 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 +427 -6
- 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,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
|
|
|
@@ -361,6 +374,15 @@ def _normalize_selectable_evm_network(value: Any) -> str:
|
|
|
361
374
|
return network
|
|
362
375
|
|
|
363
376
|
|
|
377
|
+
def _implied_evm_network_from_backend_alias(value: Any) -> str | None:
|
|
378
|
+
normalized = str(value or "").strip().lower()
|
|
379
|
+
if normalized in {"base", "base-mainnet"}:
|
|
380
|
+
return "base"
|
|
381
|
+
if normalized in {"ethereum", "eth", "mainnet", "eth-mainnet"}:
|
|
382
|
+
return "ethereum"
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
364
386
|
def _normalize_solana_network(value: Any) -> str | None:
|
|
365
387
|
network = str(value or "").strip().lower()
|
|
366
388
|
if not network:
|
|
@@ -492,12 +514,17 @@ def _reject_secret_config_json(config: dict[str, Any]) -> None:
|
|
|
492
514
|
|
|
493
515
|
|
|
494
516
|
def _base_config(args: dict[str, Any], *, tool_name: str = "") -> dict[str, Any]:
|
|
517
|
+
requested_backend = args.get("backend")
|
|
495
518
|
backend = (
|
|
496
|
-
_normalize_wallet_backend(
|
|
497
|
-
if
|
|
519
|
+
_normalize_wallet_backend(requested_backend)
|
|
520
|
+
if requested_backend is not None
|
|
498
521
|
else _active_backend_for_tool(tool_name)
|
|
499
522
|
)
|
|
500
523
|
config = _effective_config_for_backend(backend)
|
|
524
|
+
if backend == "wdk_evm_local":
|
|
525
|
+
implied_evm_network = _implied_evm_network_from_backend_alias(requested_backend)
|
|
526
|
+
if implied_evm_network and args.get("network") is None:
|
|
527
|
+
config["network"] = implied_evm_network
|
|
501
528
|
if "config" in args:
|
|
502
529
|
extra = args.get("config")
|
|
503
530
|
if not isinstance(extra, dict):
|
|
@@ -611,6 +638,311 @@ def _invoke_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, An
|
|
|
611
638
|
)
|
|
612
639
|
|
|
613
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
|
+
|
|
614
946
|
def _issue_approval_token(
|
|
615
947
|
tool_name: str,
|
|
616
948
|
config: dict[str, Any],
|
|
@@ -640,6 +972,15 @@ def _issue_approval_token(
|
|
|
640
972
|
return token
|
|
641
973
|
|
|
642
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
|
+
|
|
643
984
|
def _is_solana_swap_intent_execute(params: dict[str, Any]) -> bool:
|
|
644
985
|
return str(params.get("mode") or "") == "intent_execute"
|
|
645
986
|
|
|
@@ -807,6 +1148,33 @@ def _sanitize_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
807
1148
|
|
|
808
1149
|
def _manual_tool_definitions() -> list[dict[str, Any]]:
|
|
809
1150
|
return [
|
|
1151
|
+
{
|
|
1152
|
+
"name": "get_wallet_overview",
|
|
1153
|
+
"description": (
|
|
1154
|
+
"Get a one-off wallet overview for a requested backend/network without changing the "
|
|
1155
|
+
"active Codex wallet session. Returns the same enriched balance payload used by "
|
|
1156
|
+
"get_wallet_balance."
|
|
1157
|
+
),
|
|
1158
|
+
"input_schema": {
|
|
1159
|
+
"type": "object",
|
|
1160
|
+
"properties": {
|
|
1161
|
+
"backend": {
|
|
1162
|
+
"type": "string",
|
|
1163
|
+
"description": "solana, evm, base, ethereum, btc, or bitcoin.",
|
|
1164
|
+
},
|
|
1165
|
+
"network": {
|
|
1166
|
+
"type": "string",
|
|
1167
|
+
"description": "Optional network override. Use base or ethereum for EVM.",
|
|
1168
|
+
},
|
|
1169
|
+
"address": {
|
|
1170
|
+
"type": "string",
|
|
1171
|
+
"description": "Optional wallet address override.",
|
|
1172
|
+
},
|
|
1173
|
+
},
|
|
1174
|
+
"additionalProperties": False,
|
|
1175
|
+
},
|
|
1176
|
+
"read_only": True,
|
|
1177
|
+
},
|
|
810
1178
|
{
|
|
811
1179
|
"name": "get_active_wallet_backend",
|
|
812
1180
|
"description": (
|
|
@@ -909,7 +1277,13 @@ async def _handle_set_wallet_backend(params: dict[str, Any]) -> dict[str, Any]:
|
|
|
909
1277
|
# globals after the validating wallet call succeeds, so a failed switch never
|
|
910
1278
|
# leaves a stale backend paired with a freshly mutated network selection.
|
|
911
1279
|
if backend == "wdk_evm_local":
|
|
912
|
-
implied =
|
|
1280
|
+
implied = (
|
|
1281
|
+
params.get("network")
|
|
1282
|
+
or _implied_evm_network_from_backend_alias(requested)
|
|
1283
|
+
or selected_evm_network
|
|
1284
|
+
or _default_evm_network()
|
|
1285
|
+
or "ethereum"
|
|
1286
|
+
)
|
|
913
1287
|
resolved_network = _normalize_selectable_evm_network(implied)
|
|
914
1288
|
elif backend == "wdk_btc_local":
|
|
915
1289
|
resolved_network = _normalize_btc_network(
|
|
@@ -978,6 +1352,31 @@ async def _handle_set_evm_network(params: dict[str, Any]) -> dict[str, Any]:
|
|
|
978
1352
|
}
|
|
979
1353
|
|
|
980
1354
|
|
|
1355
|
+
async def _handle_get_wallet_overview(params: dict[str, Any]) -> dict[str, Any]:
|
|
1356
|
+
config = _base_config(params, tool_name="get_wallet_balance")
|
|
1357
|
+
backend = _normalize_wallet_backend(config.get("backend"))
|
|
1358
|
+
|
|
1359
|
+
effective_params: dict[str, Any] = {}
|
|
1360
|
+
if params.get("address") is not None:
|
|
1361
|
+
effective_params["address"] = params.get("address")
|
|
1362
|
+
|
|
1363
|
+
payload = await asyncio.to_thread(
|
|
1364
|
+
_invoke_read_tool_blocking, "get_wallet_balance", effective_params, config
|
|
1365
|
+
)
|
|
1366
|
+
if payload.get("ok") is False:
|
|
1367
|
+
raise RuntimeError(str(payload.get("error") or "get_wallet_overview failed"))
|
|
1368
|
+
data = payload.get("data", {})
|
|
1369
|
+
if not isinstance(data, dict):
|
|
1370
|
+
return {
|
|
1371
|
+
"requested_backend": _backend_label(backend),
|
|
1372
|
+
"requested_network": config.get("network"),
|
|
1373
|
+
"data": data,
|
|
1374
|
+
}
|
|
1375
|
+
data.setdefault("requested_backend", _backend_label(backend))
|
|
1376
|
+
data.setdefault("requested_network", config.get("network"))
|
|
1377
|
+
return data
|
|
1378
|
+
|
|
1379
|
+
|
|
981
1380
|
def _invoke_wallet_tool_blocking(
|
|
982
1381
|
tool_name: str,
|
|
983
1382
|
config: dict[str, Any],
|
|
@@ -1002,6 +1401,19 @@ def _invoke_wallet_tool_blocking(
|
|
|
1002
1401
|
return payload
|
|
1003
1402
|
|
|
1004
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
|
+
|
|
1005
1417
|
async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
1006
1418
|
config = _base_config(params, tool_name=tool_name)
|
|
1007
1419
|
backend = _normalize_wallet_backend(config.get("backend"))
|
|
@@ -1010,9 +1422,14 @@ async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[st
|
|
|
1010
1422
|
config["network"] = selected_evm_network
|
|
1011
1423
|
|
|
1012
1424
|
effective_params = dict(params)
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
+
)
|
|
1016
1433
|
|
|
1017
1434
|
if payload.get("ok") is False:
|
|
1018
1435
|
raise RuntimeError(str(payload.get("error") or f"{tool_name} failed"))
|
|
@@ -1068,6 +1485,8 @@ def build_server():
|
|
|
1068
1485
|
|
|
1069
1486
|
async def _dispatch(tool_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
1070
1487
|
params = params or {}
|
|
1488
|
+
if tool_name == "get_wallet_overview":
|
|
1489
|
+
return await _handle_get_wallet_overview(params)
|
|
1071
1490
|
if tool_name == "get_active_wallet_backend":
|
|
1072
1491
|
return await _handle_get_active_wallet_backend()
|
|
1073
1492
|
if tool_name == "set_wallet_backend":
|
|
@@ -1101,6 +1520,8 @@ def build_server():
|
|
|
1101
1520
|
|
|
1102
1521
|
|
|
1103
1522
|
def main() -> None:
|
|
1523
|
+
_install_termination_signal_handlers()
|
|
1524
|
+
_prewarm_resident_read_worker()
|
|
1104
1525
|
build_server().run(show_banner=False)
|
|
1105
1526
|
|
|
1106
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