@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.
Files changed (96) hide show
  1. package/.openclaw/AGENTS.md +98 -0
  2. package/.openclaw/extensions/agent-wallet/README.md +127 -0
  3. package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
  4. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
  5. package/.openclaw/extensions/agent-wallet/package.json +11 -0
  6. package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
  7. package/CHANGELOG.md +42 -0
  8. package/LICENSE +104 -0
  9. package/README.md +332 -0
  10. package/RELEASING.md +204 -0
  11. package/agent-wallet/.env.example +62 -0
  12. package/agent-wallet/AGENTS.md +129 -0
  13. package/agent-wallet/README.md +527 -0
  14. package/agent-wallet/agent_wallet/__init__.py +11 -0
  15. package/agent-wallet/agent_wallet/approval.py +161 -0
  16. package/agent-wallet/agent_wallet/bootstrap.py +178 -0
  17. package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
  18. package/agent-wallet/agent_wallet/config.py +382 -0
  19. package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
  20. package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
  21. package/agent-wallet/agent_wallet/exceptions.py +9 -0
  22. package/agent-wallet/agent_wallet/file_ops.py +34 -0
  23. package/agent-wallet/agent_wallet/http_client.py +25 -0
  24. package/agent-wallet/agent_wallet/models.py +66 -0
  25. package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
  26. package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
  27. package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
  28. package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
  29. package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
  30. package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
  31. package/agent-wallet/agent_wallet/providers/bags.py +259 -0
  32. package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
  33. package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
  34. package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
  35. package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
  36. package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
  37. package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
  38. package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
  39. package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
  40. package/agent-wallet/agent_wallet/solana_stake.py +103 -0
  41. package/agent-wallet/agent_wallet/solana_tx.py +93 -0
  42. package/agent-wallet/agent_wallet/spending_limits.py +101 -0
  43. package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
  44. package/agent-wallet/agent_wallet/user_wallets.py +355 -0
  45. package/agent-wallet/agent_wallet/validation.py +31 -0
  46. package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
  47. package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
  48. package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
  49. package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
  50. package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
  51. package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
  52. package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
  53. package/agent-wallet/examples/bootstrap_wallet.py +21 -0
  54. package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
  55. package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
  56. package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
  57. package/agent-wallet/openclaw.plugin.json +138 -0
  58. package/agent-wallet/pyproject.toml +31 -0
  59. package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
  60. package/agent-wallet/scripts/build_release_bundle.py +188 -0
  61. package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
  62. package/agent-wallet/scripts/install_agent_wallet.py +505 -0
  63. package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
  64. package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
  65. package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
  66. package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
  67. package/agent-wallet/scripts/security_utils.py +37 -0
  68. package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
  69. package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
  70. package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
  71. package/bin/openclaw-agent-wallet.mjs +487 -0
  72. package/install-from-github.sh +134 -0
  73. package/package.json +61 -0
  74. package/setup.sh +40 -0
  75. package/wdk-btc-wallet/README.md +325 -0
  76. package/wdk-btc-wallet/bootstrap.sh +22 -0
  77. package/wdk-btc-wallet/package-lock.json +1839 -0
  78. package/wdk-btc-wallet/package.json +18 -0
  79. package/wdk-btc-wallet/run-local.sh +21 -0
  80. package/wdk-btc-wallet/src/config.js +160 -0
  81. package/wdk-btc-wallet/src/json.js +35 -0
  82. package/wdk-btc-wallet/src/local_vault.js +432 -0
  83. package/wdk-btc-wallet/src/network_state.js +84 -0
  84. package/wdk-btc-wallet/src/server.js +257 -0
  85. package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
  86. package/wdk-evm-wallet/README.md +183 -0
  87. package/wdk-evm-wallet/bootstrap.sh +8 -0
  88. package/wdk-evm-wallet/package-lock.json +2340 -0
  89. package/wdk-evm-wallet/package.json +23 -0
  90. package/wdk-evm-wallet/run-local.sh +12 -0
  91. package/wdk-evm-wallet/src/config.js +274 -0
  92. package/wdk-evm-wallet/src/json.js +35 -0
  93. package/wdk-evm-wallet/src/local_vault.js +430 -0
  94. package/wdk-evm-wallet/src/network_state.js +92 -0
  95. package/wdk-evm-wallet/src/server.js +575 -0
  96. 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)