@agentlayer.tech/wallet 0.1.30 → 0.1.33

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 (40) hide show
  1. package/.openclaw/extensions/agent-wallet/README.md +1 -2
  2. package/.openclaw/extensions/agent-wallet/dist/index.js +6 -340
  3. package/.openclaw/extensions/agent-wallet/index.ts +6 -340
  4. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +0 -43
  5. package/.openclaw/extensions/agent-wallet/package.json +1 -1
  6. package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +1 -3
  7. package/CHANGELOG.md +60 -0
  8. package/README.md +0 -5
  9. package/agent-wallet/.env.example +0 -12
  10. package/agent-wallet/README.md +0 -35
  11. package/agent-wallet/agent_wallet/btc_user_wallets.py +32 -1
  12. package/agent-wallet/agent_wallet/config.py +11 -7
  13. package/agent-wallet/agent_wallet/evm_user_wallets.py +2 -0
  14. package/agent-wallet/agent_wallet/openclaw_adapter.py +1 -655
  15. package/agent-wallet/agent_wallet/openclaw_cli.py +0 -7
  16. package/agent-wallet/agent_wallet/providers/evm_portfolio.py +18 -42
  17. package/agent-wallet/agent_wallet/providers/jupiter.py +1 -307
  18. package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +31 -3
  19. package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +37 -3
  20. package/agent-wallet/agent_wallet/transaction_policy.py +0 -262
  21. package/agent-wallet/agent_wallet/wallet_layer/base.py +0 -100
  22. package/agent-wallet/agent_wallet/wallet_layer/solana.py +1 -1118
  23. package/agent-wallet/openclaw.plugin.json +0 -4
  24. package/agent-wallet/pyproject.toml +1 -1
  25. package/agent-wallet/scripts/install_agent_wallet.py +113 -6
  26. package/agent-wallet/scripts/install_openclaw_local_config.py +7 -5
  27. package/agent-wallet/skills/wallet-operator/SKILL.md +1 -5
  28. package/bin/openclaw-agent-wallet.mjs +434 -68
  29. package/claude-code/plugins/agent-wallet/scripts/run_mcp.sh +21 -3
  30. package/codex/plugins/agent-wallet/scripts/run_mcp.sh +18 -0
  31. package/codex/plugins/agent-wallet/server.py +2 -118
  32. package/hermes/plugins/agent_wallet/tools.py +1 -1
  33. package/package.json +1 -1
  34. package/wdk-btc-wallet/src/local_vault.js +45 -68
  35. package/wdk-btc-wallet/src/server.js +1 -0
  36. package/wdk-evm-wallet/README.md +4 -3
  37. package/wdk-evm-wallet/src/config.js +15 -0
  38. package/wdk-evm-wallet/src/local_vault.js +45 -68
  39. package/wdk-evm-wallet/src/server.js +1 -0
  40. package/agent-wallet/agent_wallet/providers/houdini.py +0 -539
@@ -46,27 +46,17 @@ HOST_DEFAULT_CONFIG_KEYS = {
46
46
  "jupiterUltraBaseUrl",
47
47
  "jupiterPriceBaseUrl",
48
48
  "jupiterPortfolioBaseUrl",
49
- "jupiterLendBaseUrl",
50
49
  "jupiterApiKey",
51
- "houdiniBaseUrl",
52
- "houdiniApiKey",
53
- "houdiniApiSecret",
54
- "houdiniUserIp",
55
- "houdiniUserAgent",
56
- "houdiniUserTimezone",
57
50
  "kaminoBaseUrl",
58
51
  "kaminoProgramId",
59
52
  }
60
53
  BACKENDS = ("solana_local", "wdk_btc_local", "wdk_evm_local")
61
54
  PREVIEW_CACHE_TTL_SECONDS = 15 * 60
62
- PRIVATE_SWAP_CACHE_TTL_SECONDS = 35 * 60
63
55
  PREVIEW_BOUND_SWAP_TOOLS = {
64
56
  "swap_solana_tokens",
65
- "swap_solana_privately",
66
57
  "flash_trade_open_position",
67
58
  "flash_trade_close_position",
68
59
  }
69
- PRIVATE_SWAP_APPROVAL_TOOL_NAME = "swap_solana_privately"
70
60
  APPROVAL_PREVIEW_TOOL_ALIASES = {
71
61
  "x402_pay_request": "x402_preview_request",
72
62
  }
@@ -81,7 +71,6 @@ selected_solana_network: str | None = None
81
71
  selected_evm_network: str | None = None
82
72
  selected_btc_network: str | None = None
83
73
  approval_preview_cache: dict[str, dict[str, Any]] = {}
84
- private_swap_order_cache: dict[str, dict[str, Any]] = {}
85
74
 
86
75
 
87
76
  class WalletCliError(RuntimeError):
@@ -245,17 +234,10 @@ def _cache_preview_for_approval(user_id: str, tool_name: str, payload: dict[str,
245
234
  _prune_approval_preview_cache()
246
235
  approval_preview_cache[_approval_cache_key(user_id, cache_tool_name)] = {
247
236
  "digest": _preview_digest(data),
248
- "expires_at": time.time()
249
- + (
250
- PRIVATE_SWAP_CACHE_TTL_SECONDS
251
- if cache_tool_name == PRIVATE_SWAP_APPROVAL_TOOL_NAME
252
- else PREVIEW_CACHE_TTL_SECONDS
253
- ),
237
+ "expires_at": time.time() + PREVIEW_CACHE_TTL_SECONDS,
254
238
  "preview": data,
255
239
  "summary": summary,
256
240
  }
257
- if cache_tool_name == PRIVATE_SWAP_APPROVAL_TOOL_NAME:
258
- private_swap_order_cache.pop(_approval_cache_key(user_id, cache_tool_name), None)
259
241
 
260
242
 
261
243
  def _latest_cached_preview(user_id: str, tool_name: str) -> dict[str, Any] | None:
@@ -290,66 +272,6 @@ def _cached_preview_for_token(user_id: str, tool_name: str, token: str) -> dict[
290
272
  return preview if isinstance(preview, dict) else None
291
273
 
292
274
 
293
- def _cache_pending_private_swap_order(
294
- user_id: str,
295
- tool_name: str,
296
- preview: dict[str, Any],
297
- details: dict[str, Any],
298
- ) -> None:
299
- if tool_name != PRIVATE_SWAP_APPROVAL_TOOL_NAME:
300
- return
301
- houdini_id = str(details.get("houdini_id") or "").strip()
302
- deposit_address = str(details.get("deposit_address") or "").strip()
303
- if not houdini_id or not deposit_address:
304
- return
305
- private_swap_order_cache[_approval_cache_key(user_id, tool_name)] = {
306
- "digest": _preview_digest(preview),
307
- "expires_at": time.time() + PRIVATE_SWAP_CACHE_TTL_SECONDS,
308
- "order": {
309
- "multi_id": str(details.get("multi_id") or "").strip() or None,
310
- "houdini_id": houdini_id,
311
- "deposit_address": deposit_address,
312
- "order": details.get("order") if isinstance(details.get("order"), dict) else {},
313
- },
314
- }
315
-
316
-
317
- def _latest_pending_private_swap_order(
318
- user_id: str,
319
- tool_name: str,
320
- preview: dict[str, Any],
321
- ) -> dict[str, Any] | None:
322
- if tool_name != PRIVATE_SWAP_APPROVAL_TOOL_NAME:
323
- return None
324
- cached = private_swap_order_cache.get(_approval_cache_key(user_id, tool_name))
325
- if not cached:
326
- return None
327
- if float(cached.get("expires_at") or 0) <= time.time():
328
- private_swap_order_cache.pop(_approval_cache_key(user_id, tool_name), None)
329
- return None
330
- if cached.get("digest") != _preview_digest(preview):
331
- return None
332
- order = cached.get("order")
333
- return order if isinstance(order, dict) else None
334
-
335
-
336
- def _clear_pending_private_swap_order(user_id: str, tool_name: str) -> None:
337
- if tool_name == PRIVATE_SWAP_APPROVAL_TOOL_NAME:
338
- private_swap_order_cache.pop(_approval_cache_key(user_id, tool_name), None)
339
-
340
-
341
- def _list_pending_private_swap_orders(user_id: str) -> list[dict[str, Any]]:
342
- key = _approval_cache_key(user_id, PRIVATE_SWAP_APPROVAL_TOOL_NAME)
343
- pending = private_swap_order_cache.get(key)
344
- if not pending or float(pending.get("expires_at") or 0) <= time.time():
345
- private_swap_order_cache.pop(key, None)
346
- return []
347
- order = pending.get("order")
348
- if not isinstance(order, dict):
349
- return []
350
- return [{**order, "expires_at_ms": int(float(pending["expires_at"]) * 1000)}]
351
-
352
-
353
275
  def _normalize_wallet_backend(value: Any) -> str:
354
276
  normalized = str(value or "").strip().lower()
355
277
  aliases = {
@@ -651,8 +573,6 @@ def _issue_approval_token(
651
573
  ]
652
574
  if preview_payload.get("is_mainnet") is True:
653
575
  extra_args.append("--mainnet-confirmed")
654
- if tool_name == PRIVATE_SWAP_APPROVAL_TOOL_NAME:
655
- extra_args.extend(["--ttl-seconds", "1800"])
656
576
  payload = _call_wallet_cli("issue-approval", extra_args)
657
577
  token = str(payload.get("approval_token") or "").strip()
658
578
  if not token:
@@ -966,9 +886,6 @@ async def _handle_set_evm_network(params: dict[str, Any]) -> dict[str, Any]:
966
886
 
967
887
 
968
888
  async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
969
- if tool_name == "list_pending_solana_private_swaps":
970
- return {"orders": _list_pending_private_swap_orders(_user_id())}
971
-
972
889
  config = _base_config(params, tool_name=tool_name)
973
890
  backend = _normalize_wallet_backend(config.get("backend"))
974
891
  if backend == "wdk_evm_local" and params.get("network") is None and selected_evm_network:
@@ -976,24 +893,7 @@ async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[st
976
893
  config["network"] = selected_evm_network
977
894
 
978
895
  effective_params = dict(params)
979
- if tool_name != "continue_solana_private_swap":
980
- _attach_approval_for_execute(tool_name, config, effective_params)
981
- else:
982
- cached = _latest_cached_preview(_user_id(), PRIVATE_SWAP_APPROVAL_TOOL_NAME)
983
- if cached and isinstance(cached.get("preview"), dict):
984
- effective_params["_approved_preview"] = cached["preview"]
985
- effective_params["approval_token"] = _issue_approval_token(
986
- PRIVATE_SWAP_APPROVAL_TOOL_NAME,
987
- config,
988
- cached["preview"],
989
- )
990
- pending = _latest_pending_private_swap_order(
991
- _user_id(), PRIVATE_SWAP_APPROVAL_TOOL_NAME, cached["preview"]
992
- )
993
- if pending and effective_params.get("_resume_private_swap_order") is None:
994
- effective_params["_resume_private_swap_order"] = pending
995
- elif not effective_params.get("approval_token"):
996
- raise RuntimeError(APPROVAL_CONTEXT_MISSING_MESSAGE)
896
+ _attach_approval_for_execute(tool_name, config, effective_params)
997
897
 
998
898
  try:
999
899
  payload = _invoke_tool(tool_name, effective_params, config)
@@ -1001,22 +901,6 @@ async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[st
1001
901
  raise _normalize_approval_context_error(exc) from exc
1002
902
 
1003
903
  _cache_preview_for_approval(_user_id(), tool_name, payload)
1004
- if tool_name == "swap_solana_privately" and payload.get("ok") is True:
1005
- data = payload.get("data")
1006
- approved_preview = effective_params.get("_approved_preview")
1007
- if (
1008
- isinstance(data, dict)
1009
- and data.get("execution_state") == "awaiting_deposit_funding"
1010
- and isinstance(approved_preview, dict)
1011
- ):
1012
- _cache_pending_private_swap_order(_user_id(), tool_name, approved_preview, data)
1013
- elif isinstance(data, dict):
1014
- _clear_pending_private_swap_order(_user_id(), tool_name)
1015
- if tool_name == "continue_solana_private_swap" and payload.get("ok") is True:
1016
- data = payload.get("data")
1017
- if isinstance(data, dict) and data.get("execution_state") == "funding_submitted":
1018
- _clear_pending_private_swap_order(_user_id(), PRIVATE_SWAP_APPROVAL_TOOL_NAME)
1019
-
1020
904
  if payload.get("ok") is False:
1021
905
  raise RuntimeError(str(payload.get("error") or f"{tool_name} failed"))
1022
906
  return payload.get("data", {})
@@ -15,7 +15,7 @@ from typing import Any
15
15
 
16
16
  SECRET_CONFIG_KEYS = {"privateKey", "masterKey", "approvalSecret"}
17
17
  BACKENDS = ("solana_local", "wdk_btc_local", "wdk_evm_local")
18
- PREVIEW_BOUND_SWAP_TOOLS = {"swap_solana_tokens", "swap_solana_privately"}
18
+ PREVIEW_BOUND_SWAP_TOOLS = {"swap_solana_tokens"}
19
19
 
20
20
 
21
21
  def _json(data: dict[str, Any]) -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.30",
3
+ "version": "0.1.33",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -26,14 +26,6 @@ function assertPositiveInteger(value, fieldName) {
26
26
  return parsed;
27
27
  }
28
28
 
29
- function assertNonNegativeInteger(value, fieldName) {
30
- const parsed = Number(value);
31
- if (!Number.isInteger(parsed) || parsed < 0) {
32
- throw new Error(`${fieldName} must be a non-negative integer.`);
33
- }
34
- return parsed;
35
- }
36
-
37
29
  function sanitizeLabel(label) {
38
30
  const normalized = String(label ?? "").trim();
39
31
  return normalized || "BTC Wallet";
@@ -67,6 +59,7 @@ async function encryptSeedPhrase({ seedPhrase, password, walletId }) {
67
59
  cipher.final(),
68
60
  ]);
69
61
  const tag = cipher.getAuthTag();
62
+ key.fill(0);
70
63
  return {
71
64
  version: VAULT_VERSION,
72
65
  kdf: {
@@ -95,7 +88,12 @@ async function decryptSeedPhrase({ encrypted, password, walletId }) {
95
88
  decipher.setAAD(Buffer.from(`wdk-btc-wallet:${walletId}:v${VAULT_VERSION}`, "utf8"));
96
89
  decipher.setAuthTag(tag);
97
90
  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
98
- return plaintext.toString("utf8");
91
+ key.fill(0);
92
+ // The returned string is an unavoidable transient (V8 strings are immutable);
93
+ // the derived key and plaintext buffers are zeroized so no zeroizable secret lingers.
94
+ const seedPhrase = plaintext.toString("utf8");
95
+ plaintext.fill(0);
96
+ return seedPhrase;
99
97
  }
100
98
 
101
99
  async function decryptSeedPhraseWithPasswordCheck(args) {
@@ -117,7 +115,6 @@ async function decryptSeedPhraseWithPasswordCheck(args) {
117
115
  export class LocalBtcVault {
118
116
  constructor(config) {
119
117
  this.config = config;
120
- this._unlocked = new Map();
121
118
  }
122
119
 
123
120
  async createWallet({
@@ -139,10 +136,9 @@ export class LocalBtcVault {
139
136
  source: "created",
140
137
  network,
141
138
  });
142
- await this.unlockWallet({ walletId: wallet.walletId, password, timeoutSeconds: 0 });
143
139
  return {
144
140
  ...wallet,
145
- unlocked: true,
141
+ unlocked: false,
146
142
  unlockExpiresAt: null,
147
143
  ...(revealSeedPhrase ? { seedPhrase } : {}),
148
144
  };
@@ -160,39 +156,35 @@ export class LocalBtcVault {
160
156
  source: "imported",
161
157
  network,
162
158
  });
163
- await this.unlockWallet({ walletId: wallet.walletId, password, timeoutSeconds: 0 });
164
159
  return {
165
160
  ...wallet,
166
- unlocked: true,
161
+ unlocked: false,
167
162
  unlockExpiresAt: null,
168
163
  };
169
164
  }
170
165
 
171
166
  async listWallets() {
172
- this.#sweepExpiredUnlocked();
173
167
  const registry = await this.#loadRegistry();
174
- return registry.wallets.map((wallet) => {
175
- const unlocked = this._unlocked.get(wallet.walletId);
176
- return {
177
- ...wallet,
178
- unlocked: Boolean(unlocked),
179
- unlockExpiresAt: unlocked ? unlocked.expiresAt : null,
180
- };
181
- });
168
+ return registry.wallets.map((wallet) => ({
169
+ ...wallet,
170
+ unlocked: false,
171
+ unlockExpiresAt: null,
172
+ }));
182
173
  }
183
174
 
184
175
  async getWallet({ walletId }) {
185
- this.#sweepExpiredUnlocked();
186
176
  const wallet = await this.#getWalletMetadata(assertNonEmptyString(walletId, "walletId"));
187
- const unlocked = this._unlocked.get(wallet.walletId);
188
177
  return {
189
178
  ...wallet,
190
- unlocked: Boolean(unlocked),
191
- unlockExpiresAt: unlocked ? unlocked.expiresAt : null,
179
+ unlocked: false,
180
+ unlockExpiresAt: null,
192
181
  };
193
182
  }
194
183
 
195
- async unlockWallet({ walletId, password, timeoutSeconds }) {
184
+ // Deprecated: the wallet now uses a decrypt-on-demand model and never holds a
185
+ // plaintext seed in memory between requests. This endpoint only verifies the
186
+ // password so callers get feedback; it does not persist any unlocked state.
187
+ async unlockWallet({ walletId, password }) {
196
188
  const metadata = await this.#getWalletMetadata(assertNonEmptyString(walletId, "walletId"));
197
189
  const encrypted = await this.#loadEncryptedWallet(walletId);
198
190
  const secret = await decryptSeedPhraseWithPasswordCheck({
@@ -203,28 +195,21 @@ export class LocalBtcVault {
203
195
  if (!WDK.isValidSeed(secret)) {
204
196
  throw new Error("Decrypted wallet seed phrase is invalid.");
205
197
  }
206
- const ttl =
207
- timeoutSeconds === undefined || timeoutSeconds === null
208
- ? this.config.unlockTimeoutSeconds
209
- : assertNonNegativeInteger(timeoutSeconds, "timeoutSeconds");
210
- const expiresAt = ttl === 0 ? null : new Date(Date.now() + ttl * 1000).toISOString();
211
- this._unlocked.set(walletId, {
212
- seedPhrase: secret,
213
- expiresAt,
214
- });
215
198
  return {
216
199
  walletId,
217
200
  label: metadata.label,
218
- unlocked: true,
219
- unlockExpiresAt: expiresAt,
201
+ unlocked: false,
202
+ unlockExpiresAt: null,
203
+ deprecated: true,
220
204
  };
221
205
  }
222
206
 
207
+ // Deprecated no-op: the wallet is always sealed at rest in the decrypt-on-demand model.
223
208
  async lockWallet({ walletId }) {
224
- this._unlocked.delete(assertNonEmptyString(walletId, "walletId"));
225
209
  return {
226
- walletId,
210
+ walletId: assertNonEmptyString(walletId, "walletId"),
227
211
  unlocked: false,
212
+ deprecated: true,
228
213
  };
229
214
  }
230
215
 
@@ -283,25 +268,19 @@ export class LocalBtcVault {
283
268
  };
284
269
  await this.#saveRegistry(registry);
285
270
 
286
- const unlocked = this._unlocked.get(id);
287
- if (unlocked) {
288
- this._unlocked.set(id, {
289
- seedPhrase,
290
- expiresAt: unlocked.expiresAt,
291
- });
292
- }
293
-
294
271
  return {
295
272
  walletId: id,
296
273
  label: metadata.label,
297
274
  passwordChanged: true,
298
275
  updatedAt,
299
- unlocked: Boolean(this._unlocked.get(id)),
300
- unlockExpiresAt: this._unlocked.get(id)?.expiresAt ?? null,
276
+ unlocked: false,
277
+ unlockExpiresAt: null,
301
278
  };
302
279
  }
303
280
 
304
- async resolveSeedPhrase({ walletId, seedPhrase }) {
281
+ // Decrypt-on-demand: the seed is decrypted just-in-time for a single signing
282
+ // request from the supplied password, never persisted between requests.
283
+ async resolveSeedPhrase({ walletId, seedPhrase, password }) {
305
284
  if (typeof seedPhrase === "string" && seedPhrase.trim()) {
306
285
  if (!WDK.isValidSeed(seedPhrase.trim())) {
307
286
  throw new Error("seedPhrase must be a valid BIP-39 seed phrase.");
@@ -313,16 +292,23 @@ export class LocalBtcVault {
313
292
  };
314
293
  }
315
294
  const id = assertNonEmptyString(walletId, "walletId");
316
- this.#sweepExpiredUnlocked();
317
- const unlocked = this._unlocked.get(id);
318
- if (!unlocked) {
319
- throw new Error("Wallet is locked. Unlock it first or provide seedPhrase explicitly.");
295
+ if (typeof password !== "string" || !password.trim()) {
296
+ throw new Error("Wallet is locked. Provide password or seedPhrase explicitly.");
297
+ }
298
+ await this.#getWalletMetadata(id);
299
+ const encrypted = await this.#loadEncryptedWallet(id);
300
+ const secret = await decryptSeedPhraseWithPasswordCheck({
301
+ encrypted,
302
+ password: password.trim(),
303
+ walletId: id,
304
+ });
305
+ if (!WDK.isValidSeed(secret)) {
306
+ throw new Error("Decrypted wallet seed phrase is invalid.");
320
307
  }
321
308
  return {
322
- seedPhrase: unlocked.seedPhrase,
323
- source: "local-vault",
309
+ seedPhrase: secret,
310
+ source: "local-vault-jit",
324
311
  walletId: id,
325
- unlockExpiresAt: unlocked.expiresAt,
326
312
  };
327
313
  }
328
314
 
@@ -420,13 +406,4 @@ export class LocalBtcVault {
420
406
  #registryPath() {
421
407
  return path.join(this.config.dataDir, REGISTRY_FILE);
422
408
  }
423
-
424
- #sweepExpiredUnlocked() {
425
- const now = Date.now();
426
- for (const [walletId, state] of this._unlocked.entries()) {
427
- if (state.expiresAt && Date.parse(state.expiresAt) <= now) {
428
- this._unlocked.delete(walletId);
429
- }
430
- }
431
- }
432
409
  }
@@ -44,6 +44,7 @@ async function withResolvedSeed(body = {}) {
44
44
  const resolved = await vault.resolveSeedPhrase({
45
45
  walletId: body.walletId,
46
46
  seedPhrase: body.seedPhrase,
47
+ password: body.password,
47
48
  });
48
49
  return {
49
50
  ...body,
@@ -165,9 +165,10 @@ Gateway mode:
165
165
  - `PROVIDER_GATEWAY_URL` defaults to `https://agent-layer-production.up.railway.app`
166
166
  - set `PROVIDER_GATEWAY_URL=https://...` only when overriding the hosted default
167
167
  - `PROVIDER_GATEWAY_BEARER_TOKEN` is optional and only needed when the gateway is protected
168
- - optionally set `WDK_EVM_RPC_GATEWAY_PROVIDER=alchemy|shared|auto`
169
- - in gateway mode, `ethereum` and `base` use the provider gateway raw EVM RPC route
170
- - explicit `WDK_EVM_<NETWORK>_RPC_URL` values still override gateway mode per network
168
+ - `ethereum` and `base` mainnet are always routed through the provider gateway raw EVM RPC route
169
+ - `ethereum` and `base` mainnet are pinned to the gateway `provider=alchemy` path
170
+ - direct `WDK_EVM_ETHEREUM_RPC_URL` and `WDK_EVM_BASE_RPC_URL` values no longer override mainnet routing
171
+ - `WDK_EVM_SEPOLIA_RPC_URL` and `WDK_EVM_BASE_SEPOLIA_RPC_URL` remain direct per-network testnet overrides
171
172
 
172
173
  Local security note:
173
174
 
@@ -10,6 +10,7 @@ const DEFAULTS = {
10
10
  unlockTimeoutSeconds: 0,
11
11
  };
12
12
  const DEFAULT_PROVIDER_GATEWAY_URL = "https://agent-layer-production.up.railway.app";
13
+ const ENFORCED_GATEWAY_MAINNETS = new Set(["ethereum", "base"]);
13
14
 
14
15
  const DEFAULT_NETWORK_PROFILES = {
15
16
  ethereum: {
@@ -191,6 +192,20 @@ export function loadConfig(env = process.env) {
191
192
 
192
193
  function resolveProviderUrl(networkKey, envValue, fallbackUrl) {
193
194
  const direct = String(envValue ?? "").trim();
195
+ if (ENFORCED_GATEWAY_MAINNETS.has(networkKey)) {
196
+ const enforcedGatewayUrl = buildGatewayEvmRpcUrl(
197
+ providerGatewayUrl,
198
+ networkKey,
199
+ "alchemy",
200
+ providerGatewayToken
201
+ );
202
+ if (!enforcedGatewayUrl) {
203
+ throw new Error(
204
+ `PROVIDER_GATEWAY_URL is required for ${networkKey} mainnet RPC routing.`
205
+ );
206
+ }
207
+ return enforcedGatewayUrl;
208
+ }
194
209
  if (direct) {
195
210
  return direct;
196
211
  }