@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,382 @@
|
|
|
1
|
+
"""Configuration for agent wallet backends."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
|
|
8
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
DEFAULT_PROVIDER_GATEWAY_URL = "https://agent-layer-production.up.railway.app"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
agent_wallet_backend: str = "none"
|
|
14
|
+
agent_wallet_sign_only: bool = False
|
|
15
|
+
agent_wallet_boot_key: str = ""
|
|
16
|
+
agent_wallet_approval_ttl_seconds: int = 600
|
|
17
|
+
agent_wallet_per_user_key_derivation: bool = True
|
|
18
|
+
agent_wallet_encrypt_user_wallets: bool = True
|
|
19
|
+
agent_wallet_migrate_plaintext_user_wallets: bool = True
|
|
20
|
+
agent_wallet_refuse_mainnet_wallet_recreation: bool = True
|
|
21
|
+
agent_wallet_require_encrypted_mainnet: bool = True
|
|
22
|
+
agent_wallet_max_per_tx_sol: float = 0
|
|
23
|
+
agent_wallet_max_hourly_sol: float = 0
|
|
24
|
+
agent_wallet_max_daily_sol: float = 0
|
|
25
|
+
agent_wallet_max_txs_per_minute: int = 0
|
|
26
|
+
|
|
27
|
+
solana_network: str = "mainnet"
|
|
28
|
+
solana_rpc_url: str = ""
|
|
29
|
+
solana_rpc_urls: str = ""
|
|
30
|
+
solana_rpc_provider_mode: str = "auto"
|
|
31
|
+
solana_commitment: str = "confirmed"
|
|
32
|
+
solana_auto_create_wallet: bool = False
|
|
33
|
+
solana_agent_public_key: str = ""
|
|
34
|
+
solana_agent_keypair_path: str = ""
|
|
35
|
+
provider_gateway_url: str = DEFAULT_PROVIDER_GATEWAY_URL
|
|
36
|
+
provider_gateway_bearer_token: str = ""
|
|
37
|
+
provider_gateway_rpc_provider: str = "auto"
|
|
38
|
+
solana_swap_provider: str = "auto"
|
|
39
|
+
wdk_btc_service_url: str = "http://127.0.0.1:8080"
|
|
40
|
+
wdk_btc_wallet_id: str = ""
|
|
41
|
+
wdk_btc_account_index: int = 0
|
|
42
|
+
wdk_evm_service_url: str = "http://127.0.0.1:8081"
|
|
43
|
+
wdk_evm_wallet_id: str = ""
|
|
44
|
+
wdk_evm_account_index: int = 0
|
|
45
|
+
|
|
46
|
+
jupiter_api_base_url: str = "https://lite-api.jup.ag/swap/v1"
|
|
47
|
+
jupiter_ultra_api_base_url: str = "https://lite-api.jup.ag/ultra/v1"
|
|
48
|
+
jupiter_price_api_base_url: str = "https://lite-api.jup.ag/price/v3"
|
|
49
|
+
jupiter_portfolio_api_base_url: str = "https://api.jup.ag/portfolio/v1"
|
|
50
|
+
jupiter_lend_api_base_url: str = "https://api.jup.ag/lend/v1"
|
|
51
|
+
jupiter_api_key: str = ""
|
|
52
|
+
lifi_api_base_url: str = "https://li.quest/v1"
|
|
53
|
+
lifi_api_key: str = ""
|
|
54
|
+
lifi_integrator: str = "openclaw"
|
|
55
|
+
lifi_default_deny_bridges: str = "mayan"
|
|
56
|
+
kamino_api_base_url: str = "https://api.kamino.finance"
|
|
57
|
+
kamino_program_id: str = "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD"
|
|
58
|
+
alchemy_api_key: str = ""
|
|
59
|
+
helius_api_key: str = ""
|
|
60
|
+
|
|
61
|
+
http_timeout: float = 10.0
|
|
62
|
+
|
|
63
|
+
model_config = {
|
|
64
|
+
"env_file": str(PACKAGE_ROOT / ".env"),
|
|
65
|
+
"env_file_encoding": "utf-8",
|
|
66
|
+
"extra": "ignore",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
settings = Settings()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_provider_mode(value: str | None) -> str:
|
|
74
|
+
mode = (value or "").strip().lower()
|
|
75
|
+
if not mode:
|
|
76
|
+
return "auto"
|
|
77
|
+
aliases = {
|
|
78
|
+
"direct": "user_direct",
|
|
79
|
+
"proxy": "shared_proxy",
|
|
80
|
+
"shared": "shared_proxy",
|
|
81
|
+
}
|
|
82
|
+
mode = aliases.get(mode, mode)
|
|
83
|
+
if mode not in {"auto", "user_direct", "shared_proxy"}:
|
|
84
|
+
return "auto"
|
|
85
|
+
return mode
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalize_rpc_provider(value: str | None) -> str:
|
|
89
|
+
provider = (value or "").strip().lower()
|
|
90
|
+
if not provider:
|
|
91
|
+
return "auto"
|
|
92
|
+
if provider not in {"auto", "shared", "helius", "alchemy"}:
|
|
93
|
+
return "auto"
|
|
94
|
+
return provider
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_swap_provider(value: str | None) -> str:
|
|
98
|
+
provider = (value or "").strip().lower()
|
|
99
|
+
if not provider:
|
|
100
|
+
return "auto"
|
|
101
|
+
aliases = {
|
|
102
|
+
"proxy": "jupiter",
|
|
103
|
+
"shared": "jupiter",
|
|
104
|
+
"bags": "jupiter",
|
|
105
|
+
}
|
|
106
|
+
provider = aliases.get(provider, provider)
|
|
107
|
+
if provider not in {"auto", "jupiter"}:
|
|
108
|
+
return "auto"
|
|
109
|
+
return provider
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_openclaw_home() -> Path:
|
|
113
|
+
"""Resolve the default OpenClaw home directory for plugin state."""
|
|
114
|
+
raw = os.getenv("OPENCLAW_HOME", "~/.openclaw")
|
|
115
|
+
return Path(raw).expanduser()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def default_solana_wallet_path(network: str) -> Path:
|
|
119
|
+
"""Return the default keypair path for a Solana wallet."""
|
|
120
|
+
return resolve_openclaw_home() / "wallets" / f"solana-{network}-agent.json"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_solana_rpc_url(network: str, configured: str) -> str:
|
|
124
|
+
"""Resolve the effective Solana RPC URL from network + optional override."""
|
|
125
|
+
if configured.strip():
|
|
126
|
+
return configured.strip()
|
|
127
|
+
|
|
128
|
+
mapping = {
|
|
129
|
+
"mainnet": "https://api.mainnet-beta.solana.com",
|
|
130
|
+
"devnet": "https://api.devnet.solana.com",
|
|
131
|
+
"testnet": "https://api.testnet.solana.com",
|
|
132
|
+
}
|
|
133
|
+
return mapping.get(network.strip().lower(), mapping["mainnet"])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_solana_rpc_urls(
|
|
137
|
+
network: str,
|
|
138
|
+
configured: str,
|
|
139
|
+
configured_list: str = "",
|
|
140
|
+
) -> list[str]:
|
|
141
|
+
"""Resolve the ordered list of Solana RPC URLs to try."""
|
|
142
|
+
candidates: list[str] = []
|
|
143
|
+
for raw in (configured_list or "").split(","):
|
|
144
|
+
value = raw.strip()
|
|
145
|
+
if value and value not in candidates:
|
|
146
|
+
candidates.append(value)
|
|
147
|
+
|
|
148
|
+
primary = resolve_solana_rpc_url(network, configured)
|
|
149
|
+
if primary and primary not in candidates:
|
|
150
|
+
candidates.insert(0, primary)
|
|
151
|
+
|
|
152
|
+
official = resolve_solana_rpc_url(network, "")
|
|
153
|
+
if official and official not in candidates:
|
|
154
|
+
candidates.append(official)
|
|
155
|
+
|
|
156
|
+
return candidates
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build_provider_gateway_rpc_url(base_url: str, provider: str, network: str) -> str:
|
|
160
|
+
return f"gateway::{provider}::{network.strip().lower()}::{base_url.rstrip('/')}/v1/rpc"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_runtime_solana_rpc_config(
|
|
164
|
+
network: str,
|
|
165
|
+
configured: str,
|
|
166
|
+
configured_list: str = "",
|
|
167
|
+
) -> dict[str, object]:
|
|
168
|
+
"""Resolve Solana RPC transport config for one runtime invocation.
|
|
169
|
+
|
|
170
|
+
Preference order:
|
|
171
|
+
1. explicit direct RPC env/config
|
|
172
|
+
2. user-provided provider API keys
|
|
173
|
+
3. shared proxy gateway
|
|
174
|
+
4. public official fallback
|
|
175
|
+
"""
|
|
176
|
+
mode = _normalize_provider_mode(
|
|
177
|
+
os.getenv("SOLANA_RPC_PROVIDER_MODE", settings.solana_rpc_provider_mode)
|
|
178
|
+
)
|
|
179
|
+
gateway_url = os.getenv("PROVIDER_GATEWAY_URL", settings.provider_gateway_url).strip()
|
|
180
|
+
gateway_provider = _normalize_rpc_provider(
|
|
181
|
+
os.getenv("PROVIDER_GATEWAY_RPC_PROVIDER", settings.provider_gateway_rpc_provider)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
env_primary = os.getenv("SOLANA_RPC_URL", "").strip()
|
|
185
|
+
env_list = os.getenv("SOLANA_RPC_URLS", "").strip()
|
|
186
|
+
if env_primary:
|
|
187
|
+
return {
|
|
188
|
+
"mode": "user_direct",
|
|
189
|
+
"provider": "custom",
|
|
190
|
+
"transport": "direct",
|
|
191
|
+
"rpc_urls": resolve_solana_rpc_urls(network, env_primary, env_list),
|
|
192
|
+
}
|
|
193
|
+
if env_list:
|
|
194
|
+
official = resolve_solana_rpc_url(network, "")
|
|
195
|
+
candidates = [item.strip() for item in env_list.split(",") if item.strip()]
|
|
196
|
+
if official and official not in candidates:
|
|
197
|
+
candidates.append(official)
|
|
198
|
+
return {
|
|
199
|
+
"mode": "user_direct",
|
|
200
|
+
"provider": "custom",
|
|
201
|
+
"transport": "direct",
|
|
202
|
+
"rpc_urls": candidates,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
alchemy_key = os.getenv("ALCHEMY_API_KEY", settings.alchemy_api_key).strip()
|
|
206
|
+
if alchemy_key:
|
|
207
|
+
alchemy_base_by_network = {
|
|
208
|
+
"mainnet": "https://solana-mainnet.g.alchemy.com/v2",
|
|
209
|
+
"devnet": "https://solana-devnet.g.alchemy.com/v2",
|
|
210
|
+
}
|
|
211
|
+
alchemy_base = alchemy_base_by_network.get(network.strip().lower())
|
|
212
|
+
if alchemy_base:
|
|
213
|
+
return {
|
|
214
|
+
"mode": "user_direct",
|
|
215
|
+
"provider": "alchemy",
|
|
216
|
+
"transport": "direct",
|
|
217
|
+
"rpc_urls": resolve_solana_rpc_urls(
|
|
218
|
+
network,
|
|
219
|
+
f"{alchemy_base}/{alchemy_key}",
|
|
220
|
+
"",
|
|
221
|
+
),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
helius_key = os.getenv("HELIUS_API_KEY", settings.helius_api_key).strip()
|
|
225
|
+
if helius_key:
|
|
226
|
+
helius_base_by_network = {
|
|
227
|
+
"mainnet": "https://mainnet.helius-rpc.com/",
|
|
228
|
+
"devnet": "https://devnet.helius-rpc.com/",
|
|
229
|
+
}
|
|
230
|
+
helius_base = helius_base_by_network.get(network.strip().lower())
|
|
231
|
+
if helius_base:
|
|
232
|
+
return {
|
|
233
|
+
"mode": "user_direct",
|
|
234
|
+
"provider": "helius",
|
|
235
|
+
"transport": "direct",
|
|
236
|
+
"rpc_urls": resolve_solana_rpc_urls(
|
|
237
|
+
network,
|
|
238
|
+
f"{helius_base}?api-key={helius_key}",
|
|
239
|
+
"",
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if network.strip().lower() == "mainnet" and (mode == "shared_proxy" or (mode == "auto" and gateway_url)):
|
|
244
|
+
if gateway_url:
|
|
245
|
+
return {
|
|
246
|
+
"mode": "shared_proxy",
|
|
247
|
+
"provider": gateway_provider,
|
|
248
|
+
"transport": "proxy",
|
|
249
|
+
"rpc_urls": [_build_provider_gateway_rpc_url(gateway_url, gateway_provider, network)],
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"mode": "public_fallback",
|
|
254
|
+
"provider": "official",
|
|
255
|
+
"transport": "direct",
|
|
256
|
+
"rpc_urls": resolve_solana_rpc_urls(network, configured, configured_list),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def resolve_runtime_solana_rpc_urls(
|
|
261
|
+
network: str,
|
|
262
|
+
configured: str,
|
|
263
|
+
configured_list: str = "",
|
|
264
|
+
) -> list[str]:
|
|
265
|
+
"""Resolve Solana RPC URLs with deployment env taking precedence over plugin config."""
|
|
266
|
+
payload = resolve_runtime_solana_rpc_config(network, configured, configured_list)
|
|
267
|
+
return list(payload["rpc_urls"])
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def resolve_runtime_solana_swap_config(network: str) -> dict[str, str]:
|
|
271
|
+
"""Resolve the effective Solana swap provider for one runtime invocation.
|
|
272
|
+
|
|
273
|
+
Preference order:
|
|
274
|
+
1. explicit swap provider override
|
|
275
|
+
2. direct Jupiter
|
|
276
|
+
"""
|
|
277
|
+
requested = _normalize_swap_provider(
|
|
278
|
+
os.getenv("SOLANA_SWAP_PROVIDER", settings.solana_swap_provider)
|
|
279
|
+
)
|
|
280
|
+
normalized_network = network.strip().lower()
|
|
281
|
+
|
|
282
|
+
if normalized_network != "mainnet":
|
|
283
|
+
return {"provider": "jupiter", "transport": "direct"}
|
|
284
|
+
|
|
285
|
+
if requested == "jupiter":
|
|
286
|
+
return {"provider": "jupiter", "transport": "direct"}
|
|
287
|
+
|
|
288
|
+
return {"provider": "jupiter", "transport": "direct"}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
292
|
+
raw = os.getenv(name)
|
|
293
|
+
if raw is None:
|
|
294
|
+
return default
|
|
295
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def resolve_boot_key() -> str:
|
|
299
|
+
"""Resolve the boot key used to unlock sealed secrets from disk."""
|
|
300
|
+
return os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _reject_legacy_runtime_secret_env(var_name: str) -> None:
|
|
304
|
+
raw = os.getenv(var_name, "").strip()
|
|
305
|
+
if not raw:
|
|
306
|
+
return
|
|
307
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
308
|
+
|
|
309
|
+
raise WalletBackendError(
|
|
310
|
+
f"{var_name} is no longer supported for runtime secret loading. "
|
|
311
|
+
"Store runtime secrets in ~/.openclaw/sealed_keys.json and provide AGENT_WALLET_BOOT_KEY instead."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _resolve_sealed_secret(*names: str) -> str:
|
|
316
|
+
boot_key = resolve_boot_key()
|
|
317
|
+
if not boot_key:
|
|
318
|
+
return ""
|
|
319
|
+
from agent_wallet.sealed_keys import unseal_keys
|
|
320
|
+
|
|
321
|
+
secrets = unseal_keys(boot_key)
|
|
322
|
+
for name in names:
|
|
323
|
+
value = secrets.get(name)
|
|
324
|
+
if isinstance(value, str) and value.strip():
|
|
325
|
+
return value.strip()
|
|
326
|
+
return ""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def resolve_wallet_master_key() -> str:
|
|
330
|
+
"""Resolve the master key used for encrypting per-user wallet files."""
|
|
331
|
+
_reject_legacy_runtime_secret_env("AGENT_WALLET_MASTER_KEY")
|
|
332
|
+
return _resolve_sealed_secret("master_key", "masterKey")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def resolve_approval_secret() -> str:
|
|
336
|
+
"""Resolve the secret used for host-issued approval tokens."""
|
|
337
|
+
_reject_legacy_runtime_secret_env("AGENT_WALLET_APPROVAL_SECRET")
|
|
338
|
+
return _resolve_sealed_secret("approval_secret", "approvalSecret")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def resolve_solana_private_key() -> str:
|
|
342
|
+
"""Resolve the Solana signing key from env/config or the sealed secret store."""
|
|
343
|
+
_reject_legacy_runtime_secret_env("SOLANA_AGENT_PRIVATE_KEY")
|
|
344
|
+
return _resolve_sealed_secret(
|
|
345
|
+
"solana_agent_private_key",
|
|
346
|
+
"private_key",
|
|
347
|
+
"privateKey",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def use_encrypted_user_wallets() -> bool:
|
|
352
|
+
"""Per-user wallet files are always encrypted in the hardened runtime."""
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def allow_plaintext_user_wallet_migration() -> bool:
|
|
357
|
+
"""Return whether legacy plaintext per-user wallets may be migrated in place."""
|
|
358
|
+
return _env_bool(
|
|
359
|
+
"AGENT_WALLET_MIGRATE_PLAINTEXT_USER_WALLETS",
|
|
360
|
+
settings.agent_wallet_migrate_plaintext_user_wallets,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def use_per_user_key_derivation() -> bool:
|
|
365
|
+
"""Per-user wallet encryption keys are always derived from the master key."""
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def refuse_mainnet_wallet_recreation() -> bool:
|
|
370
|
+
"""Return whether mainnet wallets may be recreated when a pinned address exists."""
|
|
371
|
+
return _env_bool(
|
|
372
|
+
"AGENT_WALLET_REFUSE_MAINNET_WALLET_RECREATION",
|
|
373
|
+
settings.agent_wallet_refuse_mainnet_wallet_recreation,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def require_encrypted_mainnet() -> bool:
|
|
378
|
+
"""Return whether plaintext wallet creation is forbidden on mainnet."""
|
|
379
|
+
return _env_bool(
|
|
380
|
+
"AGENT_WALLET_REQUIRE_ENCRYPTED_MAINNET",
|
|
381
|
+
settings.agent_wallet_require_encrypted_mainnet,
|
|
382
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Encrypted storage helpers for per-user wallet secret material."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agent_wallet.config import resolve_wallet_master_key
|
|
13
|
+
from agent_wallet.file_ops import atomic_write_text
|
|
14
|
+
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
15
|
+
|
|
16
|
+
ENCRYPTED_WALLET_KIND = "openclaw-agent-wallet-secret"
|
|
17
|
+
ENCRYPTED_WALLET_VERSION = 1
|
|
18
|
+
USER_SCOPED_KEY_SALT = b"openclaw-agent-wallet-user-key-v1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_secretbox():
|
|
22
|
+
try:
|
|
23
|
+
from nacl import pwhash, secret, utils
|
|
24
|
+
except ImportError as exc:
|
|
25
|
+
raise WalletBackendError(
|
|
26
|
+
"PyNaCl is required for encrypted wallet storage."
|
|
27
|
+
) from exc
|
|
28
|
+
return pwhash, secret, utils
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _derive_key(master_key: str, salt: bytes) -> bytes:
|
|
32
|
+
if not master_key.strip():
|
|
33
|
+
raise WalletBackendError(
|
|
34
|
+
"Encrypted wallet storage requires AGENT_WALLET_BOOT_KEY and a sealed master_key."
|
|
35
|
+
)
|
|
36
|
+
pwhash, secret, _ = _load_secretbox()
|
|
37
|
+
return pwhash.argon2id.kdf(
|
|
38
|
+
secret.SecretBox.KEY_SIZE,
|
|
39
|
+
master_key.encode("utf-8"),
|
|
40
|
+
salt,
|
|
41
|
+
opslimit=pwhash.argon2id.OPSLIMIT_INTERACTIVE,
|
|
42
|
+
memlimit=pwhash.argon2id.MEMLIMIT_INTERACTIVE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _derive_user_scoped_key(
|
|
47
|
+
master_key: str,
|
|
48
|
+
*,
|
|
49
|
+
user_id: str,
|
|
50
|
+
network: str,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Derive a deterministic per-user key from the global master key."""
|
|
53
|
+
if not master_key.strip():
|
|
54
|
+
raise WalletBackendError(
|
|
55
|
+
"Encrypted wallet storage requires AGENT_WALLET_BOOT_KEY and a sealed master_key."
|
|
56
|
+
)
|
|
57
|
+
normalized_network = network.strip().lower() or "mainnet"
|
|
58
|
+
prk = hmac.new(USER_SCOPED_KEY_SALT, master_key.encode("utf-8"), hashlib.sha256).digest()
|
|
59
|
+
info = f"openclaw-wallet:{user_id}:{normalized_network}".encode("utf-8")
|
|
60
|
+
okm = hmac.new(prk, info + b"\x01", hashlib.sha256).digest()
|
|
61
|
+
return okm.hex()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_encrypted_wallet_payload(raw_text: str) -> bool:
|
|
65
|
+
"""Return True if the provided text contains an encrypted wallet envelope."""
|
|
66
|
+
try:
|
|
67
|
+
payload = json.loads(raw_text)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
return False
|
|
70
|
+
return (
|
|
71
|
+
isinstance(payload, dict)
|
|
72
|
+
and payload.get("kind") == ENCRYPTED_WALLET_KIND
|
|
73
|
+
and int(payload.get("version") or 0) == ENCRYPTED_WALLET_VERSION
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encrypt_secret_material(
|
|
78
|
+
secret_material: str,
|
|
79
|
+
*,
|
|
80
|
+
master_key: str | None = None,
|
|
81
|
+
metadata: dict[str, Any] | None = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Encrypt wallet secret material into a JSON envelope."""
|
|
84
|
+
_, secret, utils = _load_secretbox()
|
|
85
|
+
effective_master_key = master_key if master_key is not None else resolve_wallet_master_key()
|
|
86
|
+
salt = utils.random(16)
|
|
87
|
+
key = _derive_key(effective_master_key, salt)
|
|
88
|
+
box = secret.SecretBox(key)
|
|
89
|
+
encrypted = box.encrypt(secret_material.encode("utf-8"))
|
|
90
|
+
payload: dict[str, Any] = {
|
|
91
|
+
"kind": ENCRYPTED_WALLET_KIND,
|
|
92
|
+
"version": ENCRYPTED_WALLET_VERSION,
|
|
93
|
+
"cipher": "secretbox",
|
|
94
|
+
"kdf": "argon2id",
|
|
95
|
+
"salt_b64": base64.b64encode(salt).decode("ascii"),
|
|
96
|
+
"nonce_b64": base64.b64encode(encrypted.nonce).decode("ascii"),
|
|
97
|
+
"ciphertext_b64": base64.b64encode(encrypted.ciphertext).decode("ascii"),
|
|
98
|
+
}
|
|
99
|
+
if metadata:
|
|
100
|
+
payload["metadata"] = metadata
|
|
101
|
+
return json.dumps(payload, indent=2)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def decrypt_secret_material(
|
|
105
|
+
raw_text: str,
|
|
106
|
+
*,
|
|
107
|
+
master_key: str | None = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Decrypt wallet secret material from a JSON envelope."""
|
|
110
|
+
_, secret, _ = _load_secretbox()
|
|
111
|
+
try:
|
|
112
|
+
payload = json.loads(raw_text)
|
|
113
|
+
except json.JSONDecodeError as exc:
|
|
114
|
+
raise WalletBackendError("Encrypted wallet file could not be parsed.") from exc
|
|
115
|
+
|
|
116
|
+
if not isinstance(payload, dict) or payload.get("kind") != ENCRYPTED_WALLET_KIND:
|
|
117
|
+
raise WalletBackendError("Wallet file is not an encrypted wallet envelope.")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
salt = base64.b64decode(payload["salt_b64"])
|
|
121
|
+
nonce = base64.b64decode(payload["nonce_b64"])
|
|
122
|
+
ciphertext = base64.b64decode(payload["ciphertext_b64"])
|
|
123
|
+
except (KeyError, ValueError) as exc:
|
|
124
|
+
raise WalletBackendError("Encrypted wallet file is malformed.") from exc
|
|
125
|
+
|
|
126
|
+
effective_master_key = master_key if master_key is not None else resolve_wallet_master_key()
|
|
127
|
+
key = _derive_key(effective_master_key, salt)
|
|
128
|
+
box = secret.SecretBox(key)
|
|
129
|
+
try:
|
|
130
|
+
plaintext = box.decrypt(ciphertext, nonce)
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
raise WalletBackendError("Encrypted wallet file could not be decrypted.") from exc
|
|
133
|
+
return plaintext.decode("utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_wallet_secret_material(
|
|
137
|
+
path: Path,
|
|
138
|
+
*,
|
|
139
|
+
master_key: str | None = None,
|
|
140
|
+
) -> tuple[str, str]:
|
|
141
|
+
"""Load wallet secret material and return it with the detected format."""
|
|
142
|
+
raw_text = path.read_text(encoding="utf-8").strip()
|
|
143
|
+
if is_encrypted_wallet_payload(raw_text):
|
|
144
|
+
return decrypt_secret_material(raw_text, master_key=master_key), "encrypted"
|
|
145
|
+
return raw_text, "plaintext"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def write_encrypted_wallet_file(
|
|
149
|
+
path: Path,
|
|
150
|
+
secret_material: str,
|
|
151
|
+
*,
|
|
152
|
+
master_key: str | None = None,
|
|
153
|
+
metadata: dict[str, Any] | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Write encrypted wallet secret material atomically to disk."""
|
|
156
|
+
payload = encrypt_secret_material(
|
|
157
|
+
secret_material,
|
|
158
|
+
master_key=master_key,
|
|
159
|
+
metadata=metadata,
|
|
160
|
+
)
|
|
161
|
+
atomic_write_text(path, payload, mode=0o600)
|