@agentlayer.tech/wallet 0.1.0
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/AGENTS.md +98 -0
- package/.openclaw/extensions/agent-wallet/README.md +127 -0
- package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
- package/.openclaw/extensions/agent-wallet/package.json +11 -0
- package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +104 -0
- package/README.md +332 -0
- package/RELEASING.md +204 -0
- package/agent-wallet/.env.example +62 -0
- package/agent-wallet/AGENTS.md +129 -0
- package/agent-wallet/README.md +527 -0
- package/agent-wallet/agent_wallet/__init__.py +11 -0
- package/agent-wallet/agent_wallet/approval.py +161 -0
- package/agent-wallet/agent_wallet/bootstrap.py +178 -0
- package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
- package/agent-wallet/agent_wallet/config.py +382 -0
- package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
- package/agent-wallet/agent_wallet/exceptions.py +9 -0
- package/agent-wallet/agent_wallet/file_ops.py +34 -0
- package/agent-wallet/agent_wallet/http_client.py +25 -0
- package/agent-wallet/agent_wallet/models.py +66 -0
- package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
- package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
- package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
- package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
- package/agent-wallet/agent_wallet/providers/bags.py +259 -0
- package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
- package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
- package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
- package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
- package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
- package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
- package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
- package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
- package/agent-wallet/agent_wallet/solana_stake.py +103 -0
- package/agent-wallet/agent_wallet/solana_tx.py +93 -0
- package/agent-wallet/agent_wallet/spending_limits.py +101 -0
- package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
- package/agent-wallet/agent_wallet/user_wallets.py +355 -0
- package/agent-wallet/agent_wallet/validation.py +31 -0
- package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
- package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
- package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
- package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
- package/agent-wallet/examples/bootstrap_wallet.py +21 -0
- package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
- package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
- package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
- package/agent-wallet/openclaw.plugin.json +138 -0
- package/agent-wallet/pyproject.toml +31 -0
- package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
- package/agent-wallet/scripts/build_release_bundle.py +188 -0
- package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
- package/agent-wallet/scripts/install_agent_wallet.py +505 -0
- package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
- package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
- package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
- package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
- package/agent-wallet/scripts/security_utils.py +37 -0
- package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
- package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
- package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
- package/bin/openclaw-agent-wallet.mjs +487 -0
- package/install-from-github.sh +134 -0
- package/package.json +61 -0
- package/setup.sh +40 -0
- package/wdk-btc-wallet/README.md +325 -0
- package/wdk-btc-wallet/bootstrap.sh +22 -0
- package/wdk-btc-wallet/package-lock.json +1839 -0
- package/wdk-btc-wallet/package.json +18 -0
- package/wdk-btc-wallet/run-local.sh +21 -0
- package/wdk-btc-wallet/src/config.js +160 -0
- package/wdk-btc-wallet/src/json.js +35 -0
- package/wdk-btc-wallet/src/local_vault.js +432 -0
- package/wdk-btc-wallet/src/network_state.js +84 -0
- package/wdk-btc-wallet/src/server.js +257 -0
- package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
- package/wdk-evm-wallet/README.md +183 -0
- package/wdk-evm-wallet/bootstrap.sh +8 -0
- package/wdk-evm-wallet/package-lock.json +2340 -0
- package/wdk-evm-wallet/package.json +23 -0
- package/wdk-evm-wallet/run-local.sh +12 -0
- package/wdk-evm-wallet/src/config.js +274 -0
- package/wdk-evm-wallet/src/json.js +35 -0
- package/wdk-evm-wallet/src/local_vault.js +430 -0
- package/wdk-evm-wallet/src/network_state.js +92 -0
- package/wdk-evm-wallet/src/server.js +575 -0
- package/wdk-evm-wallet/src/wdk_evm_wallet.js +4981 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Client helpers for the local wdk-evm-wallet service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from agent_wallet.config import resolve_openclaw_home, settings
|
|
13
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
14
|
+
|
|
15
|
+
LOCAL_WDK_EVM_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
16
|
+
LONG_RUNNING_SEND_PATHS = {
|
|
17
|
+
"/v1/evm/aave/supply/send",
|
|
18
|
+
"/v1/evm/aave/withdraw/send",
|
|
19
|
+
"/v1/evm/aave/borrow/send",
|
|
20
|
+
"/v1/evm/aave/repay/send",
|
|
21
|
+
"/v1/evm/lido/stake_eth_for_wsteth/send",
|
|
22
|
+
"/v1/evm/lido/wrap_steth/send",
|
|
23
|
+
"/v1/evm/lido/unwrap_wsteth/send",
|
|
24
|
+
"/v1/evm/lido/request_withdrawal_steth/send",
|
|
25
|
+
"/v1/evm/lido/request_withdrawal_wsteth/send",
|
|
26
|
+
"/v1/evm/lido/claim_withdrawal/send",
|
|
27
|
+
"/v1/evm/swap/send",
|
|
28
|
+
"/v1/evm/lifi/send",
|
|
29
|
+
"/v1/evm/transfer/send",
|
|
30
|
+
"/v1/evm/token-transfer/send",
|
|
31
|
+
}
|
|
32
|
+
LONG_RUNNING_SEND_TIMEOUT_SECONDS = 120.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _error_details_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
36
|
+
details = payload.get("error_details")
|
|
37
|
+
return dict(details) if isinstance(details, dict) else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_base_url(value: str) -> str:
|
|
41
|
+
text = str(value or "").strip()
|
|
42
|
+
if not text:
|
|
43
|
+
raise WalletBackendError("WDK EVM service URL is not configured.")
|
|
44
|
+
parsed = urlparse(text)
|
|
45
|
+
if parsed.scheme not in {"http", "https"} or parsed.hostname not in LOCAL_WDK_EVM_HOSTS:
|
|
46
|
+
raise WalletBackendError("WDK EVM service URL must point to a localhost HTTP endpoint.")
|
|
47
|
+
return text.rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_local_token_path() -> Path:
|
|
51
|
+
configured = os.getenv("WDK_EVM_LOCAL_TOKEN_PATH", "").strip()
|
|
52
|
+
if configured:
|
|
53
|
+
return Path(configured).expanduser()
|
|
54
|
+
return resolve_openclaw_home() / "wdk-evm-wallet" / "local-auth-token"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_local_token() -> str:
|
|
58
|
+
direct = os.getenv("WDK_EVM_LOCAL_TOKEN", "").strip()
|
|
59
|
+
if direct:
|
|
60
|
+
return direct
|
|
61
|
+
token_path = _resolve_local_token_path()
|
|
62
|
+
if not token_path.exists():
|
|
63
|
+
raise WalletBackendError(
|
|
64
|
+
f"WDK EVM local auth token file not found: {token_path}. Start the local wdk-evm-wallet service first."
|
|
65
|
+
)
|
|
66
|
+
token = token_path.read_text(encoding="utf-8").strip()
|
|
67
|
+
if not token:
|
|
68
|
+
raise WalletBackendError(f"WDK EVM local auth token file is empty: {token_path}")
|
|
69
|
+
return token
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _timeout_for_path(path: str) -> float:
|
|
73
|
+
normalized = str(path or "").strip()
|
|
74
|
+
base_timeout = float(settings.http_timeout)
|
|
75
|
+
if normalized in LONG_RUNNING_SEND_PATHS:
|
|
76
|
+
return max(base_timeout, LONG_RUNNING_SEND_TIMEOUT_SECONDS)
|
|
77
|
+
return base_timeout
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _unwrap_payload(response: httpx.Response) -> dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
payload = response.json()
|
|
83
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
84
|
+
raise WalletBackendError(
|
|
85
|
+
f"wdk-evm-wallet returned a non-JSON response ({response.status_code}).",
|
|
86
|
+
code="network_unavailable",
|
|
87
|
+
details={
|
|
88
|
+
"service": "wdk-evm-wallet",
|
|
89
|
+
"http_status": response.status_code,
|
|
90
|
+
},
|
|
91
|
+
) from exc
|
|
92
|
+
if response.status_code >= 400 or payload.get("ok") is False:
|
|
93
|
+
detail = payload.get("error") or f"HTTP {response.status_code}"
|
|
94
|
+
raise WalletBackendError(
|
|
95
|
+
str(detail),
|
|
96
|
+
code=str(payload.get("error_code") or "").strip() or None,
|
|
97
|
+
details=_error_details_from_payload(payload),
|
|
98
|
+
)
|
|
99
|
+
data = payload.get("data")
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise WalletBackendError("wdk-evm-wallet returned an invalid response payload.")
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class WdkEvmLocalClient:
|
|
106
|
+
"""Small client for the local EVM wallet service."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, base_url: str):
|
|
109
|
+
self.base_url = _normalize_base_url(base_url)
|
|
110
|
+
self._headers = {
|
|
111
|
+
"Accept": "application/json",
|
|
112
|
+
"Authorization": f"Bearer {_load_local_token()}",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
try:
|
|
117
|
+
async with httpx.AsyncClient(
|
|
118
|
+
timeout=_timeout_for_path(path),
|
|
119
|
+
headers=self._headers,
|
|
120
|
+
follow_redirects=False,
|
|
121
|
+
trust_env=False,
|
|
122
|
+
) as client:
|
|
123
|
+
response = await client.post(f"{self.base_url}{path}", json=payload)
|
|
124
|
+
except httpx.TimeoutException as exc:
|
|
125
|
+
raise WalletBackendError(
|
|
126
|
+
"wdk-evm-wallet request timed out.",
|
|
127
|
+
code="network_unavailable",
|
|
128
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
129
|
+
) from exc
|
|
130
|
+
except httpx.RequestError as exc:
|
|
131
|
+
raise WalletBackendError(
|
|
132
|
+
f"wdk-evm-wallet request failed: {exc}",
|
|
133
|
+
code="network_unavailable",
|
|
134
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
135
|
+
) from exc
|
|
136
|
+
return _unwrap_payload(response)
|
|
137
|
+
|
|
138
|
+
async def get(self, path: str) -> dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(
|
|
141
|
+
timeout=_timeout_for_path(path),
|
|
142
|
+
headers=self._headers,
|
|
143
|
+
follow_redirects=False,
|
|
144
|
+
trust_env=False,
|
|
145
|
+
) as client:
|
|
146
|
+
response = await client.get(f"{self.base_url}{path}")
|
|
147
|
+
except httpx.TimeoutException as exc:
|
|
148
|
+
raise WalletBackendError(
|
|
149
|
+
"wdk-evm-wallet request timed out.",
|
|
150
|
+
code="network_unavailable",
|
|
151
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
152
|
+
) from exc
|
|
153
|
+
except httpx.RequestError as exc:
|
|
154
|
+
raise WalletBackendError(
|
|
155
|
+
f"wdk-evm-wallet request failed: {exc}",
|
|
156
|
+
code="network_unavailable",
|
|
157
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
158
|
+
) from exc
|
|
159
|
+
return _unwrap_payload(response)
|
|
160
|
+
|
|
161
|
+
def post_sync(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
try:
|
|
163
|
+
with httpx.Client(
|
|
164
|
+
timeout=_timeout_for_path(path),
|
|
165
|
+
headers=self._headers,
|
|
166
|
+
follow_redirects=False,
|
|
167
|
+
trust_env=False,
|
|
168
|
+
) as client:
|
|
169
|
+
response = client.post(f"{self.base_url}{path}", json=payload)
|
|
170
|
+
except httpx.TimeoutException as exc:
|
|
171
|
+
raise WalletBackendError(
|
|
172
|
+
"wdk-evm-wallet request timed out.",
|
|
173
|
+
code="network_unavailable",
|
|
174
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
175
|
+
) from exc
|
|
176
|
+
except httpx.RequestError as exc:
|
|
177
|
+
raise WalletBackendError(
|
|
178
|
+
f"wdk-evm-wallet request failed: {exc}",
|
|
179
|
+
code="network_unavailable",
|
|
180
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
181
|
+
) from exc
|
|
182
|
+
return _unwrap_payload(response)
|
|
183
|
+
|
|
184
|
+
def get_sync(self, path: str) -> dict[str, Any]:
|
|
185
|
+
try:
|
|
186
|
+
with httpx.Client(
|
|
187
|
+
timeout=_timeout_for_path(path),
|
|
188
|
+
headers=self._headers,
|
|
189
|
+
follow_redirects=False,
|
|
190
|
+
trust_env=False,
|
|
191
|
+
) as client:
|
|
192
|
+
response = client.get(f"{self.base_url}{path}")
|
|
193
|
+
except httpx.TimeoutException as exc:
|
|
194
|
+
raise WalletBackendError(
|
|
195
|
+
"wdk-evm-wallet request timed out.",
|
|
196
|
+
code="network_unavailable",
|
|
197
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
198
|
+
) from exc
|
|
199
|
+
except httpx.RequestError as exc:
|
|
200
|
+
raise WalletBackendError(
|
|
201
|
+
f"wdk-evm-wallet request failed: {exc}",
|
|
202
|
+
code="network_unavailable",
|
|
203
|
+
details={"service": "wdk-evm-wallet", "path": path},
|
|
204
|
+
) from exc
|
|
205
|
+
return _unwrap_payload(response)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Sealed key storage backed by one boot key and an encrypted file on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from agent_wallet.encrypted_storage import decrypt_secret_material, encrypt_secret_material
|
|
9
|
+
from agent_wallet.file_ops import atomic_write_text
|
|
10
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
11
|
+
|
|
12
|
+
SEALED_KEYS_FILENAME = "sealed_keys.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_sealed_keys_path() -> Path:
|
|
16
|
+
"""Resolve the encrypted secret bundle path under the OpenClaw home directory."""
|
|
17
|
+
from agent_wallet.config import resolve_openclaw_home
|
|
18
|
+
|
|
19
|
+
return resolve_openclaw_home() / SEALED_KEYS_FILENAME
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def seal_keys(boot_key: str, secrets: dict[str, str]) -> Path:
|
|
23
|
+
"""Encrypt all secrets into a single sealed file."""
|
|
24
|
+
if not boot_key.strip():
|
|
25
|
+
raise WalletBackendError("AGENT_WALLET_BOOT_KEY is required to seal secrets.")
|
|
26
|
+
normalized: dict[str, str] = {}
|
|
27
|
+
for key, value in secrets.items():
|
|
28
|
+
if not isinstance(key, str) or not key.strip():
|
|
29
|
+
raise WalletBackendError("Sealed secret names must be non-empty strings.")
|
|
30
|
+
if not isinstance(value, str):
|
|
31
|
+
raise WalletBackendError(f"Sealed secret '{key}' must be a string.")
|
|
32
|
+
normalized[key.strip()] = value
|
|
33
|
+
|
|
34
|
+
payload = json.dumps(normalized, indent=2)
|
|
35
|
+
encrypted = encrypt_secret_material(payload, master_key=boot_key)
|
|
36
|
+
path = resolve_sealed_keys_path()
|
|
37
|
+
atomic_write_text(path, encrypted, mode=0o600)
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unseal_keys(boot_key: str) -> dict[str, str]:
|
|
42
|
+
"""Decrypt all secrets from the sealed file."""
|
|
43
|
+
if not boot_key.strip():
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
path = resolve_sealed_keys_path()
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
plaintext = decrypt_secret_material(path.read_text(encoding="utf-8"), master_key=boot_key)
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(plaintext)
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
raise WalletBackendError("Sealed secret file is malformed.") from exc
|
|
55
|
+
if not isinstance(payload, dict):
|
|
56
|
+
raise WalletBackendError("Sealed secret file is malformed.")
|
|
57
|
+
secrets: dict[str, str] = {}
|
|
58
|
+
for key, value in payload.items():
|
|
59
|
+
if isinstance(key, str) and isinstance(value, str):
|
|
60
|
+
secrets[key] = value
|
|
61
|
+
return secrets
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Helpers for native Solana stake program instructions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
from solders.instruction import AccountMeta, Instruction
|
|
8
|
+
from solders.pubkey import Pubkey
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
STAKE_PROGRAM_ID = Pubkey.from_string("Stake11111111111111111111111111111111111111")
|
|
12
|
+
STAKE_CONFIG_ID = Pubkey.from_string("StakeConfig11111111111111111111111111111111")
|
|
13
|
+
SYSVAR_CLOCK_ID = Pubkey.from_string("SysvarC1ock11111111111111111111111111111111")
|
|
14
|
+
SYSVAR_STAKE_HISTORY_ID = Pubkey.from_string("SysvarStakeHistory1111111111111111111111111")
|
|
15
|
+
SYSVAR_RENT_ID = Pubkey.from_string("SysvarRent111111111111111111111111111111111")
|
|
16
|
+
STAKE_STATE_V2_SIZE = 200
|
|
17
|
+
|
|
18
|
+
STAKE_INSTR_DELEGATE = 2
|
|
19
|
+
STAKE_INSTR_WITHDRAW = 4
|
|
20
|
+
STAKE_INSTR_DEACTIVATE = 5
|
|
21
|
+
STAKE_INSTR_INITIALIZE_CHECKED = 9
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _stake_variant(index: int) -> bytes:
|
|
25
|
+
return struct.pack("<I", index)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def initialize_checked(
|
|
29
|
+
*,
|
|
30
|
+
stake_account: Pubkey,
|
|
31
|
+
staker: Pubkey,
|
|
32
|
+
withdrawer: Pubkey,
|
|
33
|
+
) -> Instruction:
|
|
34
|
+
"""Build InitializeChecked for a new stake account."""
|
|
35
|
+
return Instruction(
|
|
36
|
+
STAKE_PROGRAM_ID,
|
|
37
|
+
_stake_variant(STAKE_INSTR_INITIALIZE_CHECKED),
|
|
38
|
+
[
|
|
39
|
+
AccountMeta(stake_account, is_signer=False, is_writable=True),
|
|
40
|
+
AccountMeta(SYSVAR_RENT_ID, is_signer=False, is_writable=False),
|
|
41
|
+
AccountMeta(staker, is_signer=False, is_writable=False),
|
|
42
|
+
AccountMeta(withdrawer, is_signer=False, is_writable=False),
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delegate_stake(
|
|
48
|
+
*,
|
|
49
|
+
stake_account: Pubkey,
|
|
50
|
+
vote_account: Pubkey,
|
|
51
|
+
authority: Pubkey,
|
|
52
|
+
) -> Instruction:
|
|
53
|
+
"""Build DelegateStake for an initialized stake account."""
|
|
54
|
+
return Instruction(
|
|
55
|
+
STAKE_PROGRAM_ID,
|
|
56
|
+
_stake_variant(STAKE_INSTR_DELEGATE),
|
|
57
|
+
[
|
|
58
|
+
AccountMeta(stake_account, is_signer=False, is_writable=True),
|
|
59
|
+
AccountMeta(vote_account, is_signer=False, is_writable=False),
|
|
60
|
+
AccountMeta(SYSVAR_CLOCK_ID, is_signer=False, is_writable=False),
|
|
61
|
+
AccountMeta(SYSVAR_STAKE_HISTORY_ID, is_signer=False, is_writable=False),
|
|
62
|
+
AccountMeta(STAKE_CONFIG_ID, is_signer=False, is_writable=False),
|
|
63
|
+
AccountMeta(authority, is_signer=True, is_writable=False),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def deactivate_stake(
|
|
69
|
+
*,
|
|
70
|
+
stake_account: Pubkey,
|
|
71
|
+
authority: Pubkey,
|
|
72
|
+
) -> Instruction:
|
|
73
|
+
"""Build Deactivate for a delegated stake account."""
|
|
74
|
+
return Instruction(
|
|
75
|
+
STAKE_PROGRAM_ID,
|
|
76
|
+
_stake_variant(STAKE_INSTR_DEACTIVATE),
|
|
77
|
+
[
|
|
78
|
+
AccountMeta(stake_account, is_signer=False, is_writable=True),
|
|
79
|
+
AccountMeta(SYSVAR_CLOCK_ID, is_signer=False, is_writable=False),
|
|
80
|
+
AccountMeta(authority, is_signer=True, is_writable=False),
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def withdraw_stake(
|
|
86
|
+
*,
|
|
87
|
+
stake_account: Pubkey,
|
|
88
|
+
recipient: Pubkey,
|
|
89
|
+
authority: Pubkey,
|
|
90
|
+
lamports: int,
|
|
91
|
+
) -> Instruction:
|
|
92
|
+
"""Build Withdraw for an inactive or partially withdrawable stake account."""
|
|
93
|
+
return Instruction(
|
|
94
|
+
STAKE_PROGRAM_ID,
|
|
95
|
+
struct.pack("<IQ", STAKE_INSTR_WITHDRAW, lamports),
|
|
96
|
+
[
|
|
97
|
+
AccountMeta(stake_account, is_signer=False, is_writable=True),
|
|
98
|
+
AccountMeta(recipient, is_signer=False, is_writable=True),
|
|
99
|
+
AccountMeta(SYSVAR_CLOCK_ID, is_signer=False, is_writable=False),
|
|
100
|
+
AccountMeta(SYSVAR_STAKE_HISTORY_ID, is_signer=False, is_writable=False),
|
|
101
|
+
AccountMeta(authority, is_signer=True, is_writable=False),
|
|
102
|
+
],
|
|
103
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Minimal legacy Solana transaction builder for native SOL transfers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
from agent_wallet.wallet_layer.base58 import b58decode
|
|
9
|
+
|
|
10
|
+
SYSTEM_PROGRAM_ID = "11111111111111111111111111111111"
|
|
11
|
+
SYSTEM_TRANSFER_INSTRUCTION = 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def encode_shortvec(value: int) -> bytes:
|
|
15
|
+
"""Encode an integer using Solana's compact-u16/shortvec format."""
|
|
16
|
+
if value < 0:
|
|
17
|
+
raise ValueError("shortvec cannot encode negative values")
|
|
18
|
+
|
|
19
|
+
encoded = bytearray()
|
|
20
|
+
remaining = value
|
|
21
|
+
while True:
|
|
22
|
+
element = remaining & 0x7F
|
|
23
|
+
remaining >>= 7
|
|
24
|
+
if remaining:
|
|
25
|
+
encoded.append(element | 0x80)
|
|
26
|
+
else:
|
|
27
|
+
encoded.append(element)
|
|
28
|
+
break
|
|
29
|
+
return bytes(encoded)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_legacy_sol_transfer_message(
|
|
33
|
+
sender: str,
|
|
34
|
+
recipient: str,
|
|
35
|
+
recent_blockhash: str,
|
|
36
|
+
lamports: int,
|
|
37
|
+
) -> bytes:
|
|
38
|
+
"""Build a legacy transaction message for a native SOL transfer."""
|
|
39
|
+
if lamports <= 0:
|
|
40
|
+
raise ValueError("lamports must be greater than zero")
|
|
41
|
+
|
|
42
|
+
sender_key = b58decode(sender)
|
|
43
|
+
recipient_key = b58decode(recipient)
|
|
44
|
+
system_program_key = b58decode(SYSTEM_PROGRAM_ID)
|
|
45
|
+
blockhash_bytes = b58decode(recent_blockhash)
|
|
46
|
+
|
|
47
|
+
if len(sender_key) != 32 or len(recipient_key) != 32 or len(system_program_key) != 32:
|
|
48
|
+
raise ValueError("All account keys must decode to 32 bytes")
|
|
49
|
+
if len(blockhash_bytes) != 32:
|
|
50
|
+
raise ValueError("Recent blockhash must decode to 32 bytes")
|
|
51
|
+
|
|
52
|
+
header = bytes(
|
|
53
|
+
[
|
|
54
|
+
1, # num_required_signatures
|
|
55
|
+
0, # num_readonly_signed_accounts
|
|
56
|
+
1, # num_readonly_unsigned_accounts (system program)
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
account_keys = [sender_key, recipient_key, system_program_key]
|
|
60
|
+
message = bytearray()
|
|
61
|
+
message.extend(header)
|
|
62
|
+
message.extend(encode_shortvec(len(account_keys)))
|
|
63
|
+
for account_key in account_keys:
|
|
64
|
+
message.extend(account_key)
|
|
65
|
+
message.extend(blockhash_bytes)
|
|
66
|
+
|
|
67
|
+
instruction_data = struct.pack("<IQ", SYSTEM_TRANSFER_INSTRUCTION, lamports)
|
|
68
|
+
compiled_instruction = bytearray()
|
|
69
|
+
compiled_instruction.append(2) # system program index
|
|
70
|
+
compiled_instruction.extend(encode_shortvec(2))
|
|
71
|
+
compiled_instruction.extend(bytes([0, 1])) # sender, recipient
|
|
72
|
+
compiled_instruction.extend(encode_shortvec(len(instruction_data)))
|
|
73
|
+
compiled_instruction.extend(instruction_data)
|
|
74
|
+
|
|
75
|
+
message.extend(encode_shortvec(1))
|
|
76
|
+
message.extend(compiled_instruction)
|
|
77
|
+
return bytes(message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def serialize_legacy_transaction(signature: bytes, message: bytes) -> bytes:
|
|
81
|
+
"""Serialize a signed legacy transaction."""
|
|
82
|
+
if len(signature) != 64:
|
|
83
|
+
raise ValueError("Solana signatures must be 64 bytes")
|
|
84
|
+
payload = bytearray()
|
|
85
|
+
payload.extend(encode_shortvec(1))
|
|
86
|
+
payload.extend(signature)
|
|
87
|
+
payload.extend(message)
|
|
88
|
+
return bytes(payload)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def encode_transaction_base64(transaction_bytes: bytes) -> str:
|
|
92
|
+
"""Encode serialized transaction bytes for sendTransaction RPC."""
|
|
93
|
+
return base64.b64encode(transaction_bytes).decode("ascii")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""In-process spending ledger with configurable per-transaction, hourly, and daily limits.
|
|
2
|
+
|
|
3
|
+
All limits default to ``0`` (unlimited). When a limit is set, every
|
|
4
|
+
``check_and_record`` call verifies that the requested spend would not
|
|
5
|
+
exceed the configured thresholds. If a limit would be breached, a
|
|
6
|
+
``WalletBackendError`` is raised *before* the on-chain transaction is
|
|
7
|
+
submitted.
|
|
8
|
+
|
|
9
|
+
Thread-safe: uses ``threading.Lock`` for concurrent access. For
|
|
10
|
+
multi-instance deployments, replace ``SpendingLedger`` with a shared
|
|
11
|
+
store (Redis / SQLite) behind the same interface.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# 1 SOL = 1_000_000_000 lamports
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
LAMPORTS_PER_SOL = 1_000_000_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SpendingConfig:
|
|
30
|
+
"""Configurable spending limits (all values in lamports, 0 = unlimited)."""
|
|
31
|
+
|
|
32
|
+
max_per_tx_lamports: int = 0
|
|
33
|
+
max_hourly_lamports: int = 0
|
|
34
|
+
max_daily_lamports: int = 0
|
|
35
|
+
max_txs_per_minute: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SpendingLedger:
|
|
39
|
+
"""Records executed spends and rejects operations that exceed limits."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: SpendingConfig) -> None:
|
|
42
|
+
self.config = config
|
|
43
|
+
self._entries: list[tuple[float, int]] = [] # (monotonic-ts, lamports)
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
# -- public API ----------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def check_and_record(self, lamports: int) -> None:
|
|
49
|
+
"""Validate *lamports* against limits and record the spend.
|
|
50
|
+
|
|
51
|
+
Raises ``WalletBackendError`` if any limit would be exceeded.
|
|
52
|
+
"""
|
|
53
|
+
with self._lock:
|
|
54
|
+
now = time.monotonic()
|
|
55
|
+
self._cleanup(now)
|
|
56
|
+
|
|
57
|
+
# Per-transaction cap
|
|
58
|
+
if self.config.max_per_tx_lamports > 0:
|
|
59
|
+
if lamports > self.config.max_per_tx_lamports:
|
|
60
|
+
raise WalletBackendError(
|
|
61
|
+
f"Transaction exceeds per-tx limit: "
|
|
62
|
+
f"{lamports} > {self.config.max_per_tx_lamports} lamports "
|
|
63
|
+
f"({self.config.max_per_tx_lamports / LAMPORTS_PER_SOL:.4f} SOL)."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Transactions-per-minute rate limit
|
|
67
|
+
if self.config.max_txs_per_minute > 0:
|
|
68
|
+
recent_count = sum(1 for ts, _ in self._entries if now - ts < 60)
|
|
69
|
+
if recent_count >= self.config.max_txs_per_minute:
|
|
70
|
+
raise WalletBackendError(
|
|
71
|
+
f"Transaction rate limit exceeded: "
|
|
72
|
+
f"max {self.config.max_txs_per_minute} txs/minute."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Hourly cumulative cap
|
|
76
|
+
if self.config.max_hourly_lamports > 0:
|
|
77
|
+
hourly_total = sum(lam for ts, lam in self._entries if now - ts < 3600)
|
|
78
|
+
if hourly_total + lamports > self.config.max_hourly_lamports:
|
|
79
|
+
raise WalletBackendError(
|
|
80
|
+
f"Hourly spending limit exceeded: "
|
|
81
|
+
f"would reach {(hourly_total + lamports) / LAMPORTS_PER_SOL:.4f} SOL, "
|
|
82
|
+
f"limit is {self.config.max_hourly_lamports / LAMPORTS_PER_SOL:.4f} SOL."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Daily cumulative cap
|
|
86
|
+
if self.config.max_daily_lamports > 0:
|
|
87
|
+
daily_total = sum(lam for ts, lam in self._entries if now - ts < 86400)
|
|
88
|
+
if daily_total + lamports > self.config.max_daily_lamports:
|
|
89
|
+
raise WalletBackendError(
|
|
90
|
+
f"Daily spending limit exceeded: "
|
|
91
|
+
f"would reach {(daily_total + lamports) / LAMPORTS_PER_SOL:.4f} SOL, "
|
|
92
|
+
f"limit is {self.config.max_daily_lamports / LAMPORTS_PER_SOL:.4f} SOL."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._entries.append((now, lamports))
|
|
96
|
+
|
|
97
|
+
# -- internal ------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _cleanup(self, now: float) -> None:
|
|
100
|
+
"""Evict entries older than 24 h to bound memory usage."""
|
|
101
|
+
self._entries = [(ts, lam) for ts, lam in self._entries if now - ts < 86400]
|