@agentlayer.tech/wallet 0.1.84 → 0.1.89

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.
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
5
- "version": "0.1.84",
5
+ "version": "0.1.89",
6
6
  "contracts": {
7
7
  "tools": [
8
8
  "agentlayer_autonomous_approve",
@@ -61,6 +61,7 @@
61
61
  "manage_evm_morpho_vault_position",
62
62
  "manage_evm_lido_position",
63
63
  "manage_evm_lido_withdrawal",
64
+ "search_uniswap_pairs",
64
65
  "set_evm_network",
65
66
  "set_wallet_backend",
66
67
  "sign_wallet_message",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.84",
3
+ "version": "0.1.89",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Hardened Morpho EVM operations with a 30% gas-limit buffer calculated from
6
+ the exact final calldata immediately before sending. Morpho vault and market
7
+ writes now wait for their final on-chain receipt and report reverts instead
8
+ of returning an unconfirmed broadcast as a successful operation.
9
+
10
+ - Extended the same exact-calldata 30% gas-limit buffer and receipt validation
11
+ to Aave, Lido, Velora, LI.FI, and locally broadcast Uniswap swaps. This
12
+ includes Robinhood Chain's Universal Router, direct V3 fallback, and native
13
+ wrap/unwrap paths; UniswapX orders remain order submissions, not local
14
+ on-chain confirmations.
15
+
16
+ - Fixed the Robinhood Chain Uniswap default to Universal Router `2.1.1`.
17
+ `UNISWAP_ROUTER_VERSION_BY_NETWORK` can still explicitly override it per
18
+ network; the legacy global `2.0` default continues to serve Ethereum and Base.
19
+
5
20
  ## v0.1.77 - 2026-07-13
6
21
 
7
22
  - Added Robinhood Chain mainnet (`chainId` 4663) to the local EVM wallet,
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.84
1
+ 0.1.89
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Keep in sync with package.json, pyproject.toml, and the npm installer version.
4
4
  # scripts/check_release_version.mjs enforces this on release.
5
- __version__ = "0.1.84"
5
+ __version__ = "0.1.89"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -2211,6 +2211,67 @@ class OpenClawWalletAdapter:
2211
2211
  risk_level="low",
2212
2212
  ),
2213
2213
  )
2214
+ tools.append(
2215
+ AgentToolSpec(
2216
+ name="search_uniswap_pairs",
2217
+ description=(
2218
+ "Search for onchain Uniswap-tradeable token pairs by ticker/name or by exact "
2219
+ "ERC-20 address, with live price, 5m/1h/6h/24h volume, liquidity, FDV, market "
2220
+ "cap, and buy/sell counts. Backed by DexScreener's public index (covers "
2221
+ "Uniswap v2/v3/v4 pools and other DEXes on the same chain), since the Uniswap "
2222
+ "Trading API itself only quotes a pair you already know the addresses for, not "
2223
+ "search-by-name. Defaults to the currently active EVM network (e.g. robinhood); "
2224
+ "set all_chains to search across every chain DexScreener indexes. Read-only "
2225
+ "market data, not a swap quote. Free-text search can surface impersonator "
2226
+ "tokens reusing a legitimate ticker with fabricated liquidity/FDV — before "
2227
+ "quoting or swapping, verify the token_address independently (e.g. via "
2228
+ "get_evm_token_metadata) rather than trusting the top result by symbol match. "
2229
+ "Do not use liquidity/FDV size to pick a winner: observed impersonators on "
2230
+ "robinhood fabricate liquidity/FDV an order of magnitude above the genuine "
2231
+ "pool specifically to look most trustworthy, and a Uniswap quote succeeding "
2232
+ "for a token proves nothing either (Trading API routes any pool with reserves, "
2233
+ "impersonators included). This only matters for tickers claiming to represent "
2234
+ "a real-world asset (stocks/ETFs) - unrelated meme/community tokens on the "
2235
+ "same chain aren't impersonators just for lacking a canonical issuer. When the "
2236
+ "query is a real-world-asset ticker, cross-check the returned token_address "
2237
+ "against Robinhood's own canonical list at docs.robinhood.com/chain/contracts "
2238
+ "(or the equivalent official source for other chains) before relying on the result."
2239
+ ),
2240
+ input_schema={
2241
+ "type": "object",
2242
+ "properties": {
2243
+ "query": {
2244
+ "type": "string",
2245
+ "description": "Free-text search, e.g. a ticker or name (\"AAPL\", \"Apple Robinhood\"). Required unless token_address is given.",
2246
+ },
2247
+ "token_address": {
2248
+ "type": "string",
2249
+ "description": "Exact ERC-20 contract address to look up instead of a free-text query.",
2250
+ },
2251
+ "chain": {
2252
+ "type": "string",
2253
+ "description": "DexScreener chain slug to filter to (e.g. \"robinhood\", \"ethereum\", \"base\"). Defaults to the active EVM network.",
2254
+ },
2255
+ "dex_id": {
2256
+ "type": "string",
2257
+ "description": "Optional exact DEX filter, e.g. \"uniswap\".",
2258
+ },
2259
+ "all_chains": {
2260
+ "type": "boolean",
2261
+ "description": "If true, do not filter query results to a single chain. Ignored when token_address is set.",
2262
+ },
2263
+ "limit": {
2264
+ "type": "integer",
2265
+ "description": "Max number of pairs to return (1-30). Defaults to 10.",
2266
+ },
2267
+ },
2268
+ "required": [],
2269
+ "additionalProperties": False,
2270
+ },
2271
+ read_only=True,
2272
+ risk_level="low",
2273
+ ),
2274
+ )
2214
2275
  tools.insert(
2215
2276
  12,
2216
2277
  AgentToolSpec(
@@ -5460,6 +5521,37 @@ class OpenClawWalletAdapter:
5460
5521
  )
5461
5522
  return AgentToolResult(tool=tool_name, ok=True, data=data)
5462
5523
 
5524
+ if tool_name == "search_uniswap_pairs":
5525
+ query = args.get("query")
5526
+ token_address = args.get("token_address")
5527
+ chain = args.get("chain")
5528
+ dex_id = args.get("dex_id")
5529
+ all_chains = bool(args.get("all_chains", False))
5530
+ limit = args.get("limit", 10)
5531
+ if query is not None and not isinstance(query, str):
5532
+ raise WalletBackendError("query must be a string.")
5533
+ if token_address is not None and not isinstance(token_address, str):
5534
+ raise WalletBackendError("token_address must be a string.")
5535
+ if not (isinstance(query, str) and query.strip()) and not (
5536
+ isinstance(token_address, str) and token_address.strip()
5537
+ ):
5538
+ raise WalletBackendError("Either query or token_address is required.")
5539
+ if chain is not None and not isinstance(chain, str):
5540
+ raise WalletBackendError("chain must be a string.")
5541
+ if dex_id is not None and not isinstance(dex_id, str):
5542
+ raise WalletBackendError("dex_id must be a string.")
5543
+ if not isinstance(limit, int) or limit < 1 or limit > 30:
5544
+ raise WalletBackendError("limit must be an integer between 1 and 30.")
5545
+ data = await active_backend.search_uniswap_pairs(
5546
+ query=query,
5547
+ token_address=token_address,
5548
+ chain=chain,
5549
+ dex_id=dex_id,
5550
+ all_chains=all_chains,
5551
+ limit=limit,
5552
+ )
5553
+ return AgentToolResult(tool=tool_name, ok=True, data=data)
5554
+
5463
5555
  if tool_name == "swap_evm_uniswap_tokens":
5464
5556
  token_in = args.get("token_in")
5465
5557
  token_out = args.get("token_out")
@@ -0,0 +1,145 @@
1
+ """DexScreener provider for onchain token/pair discovery (price, volume, liquidity).
2
+
3
+ Public API, no key required: https://docs.dexscreener.com/api/reference. Used to
4
+ answer "which pairs exist for this token/ticker" the way app.uniswap.org's own
5
+ search box does, since the Uniswap Trading API itself only exposes quote/order/swap
6
+ for a pair you already know, not a search-by-name/address endpoint.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any
13
+
14
+ from agent_wallet.exceptions import ProviderError
15
+ from agent_wallet.http_client import get_client
16
+
17
+ DEXSCREENER_BASE_URL = "https://api.dexscreener.com"
18
+
19
+ # DexScreener's own responses are served with `cache-control: public, max-age=30`;
20
+ # mirroring that TTL avoids redundant lookups within a session without serving
21
+ # data staler than DexScreener itself would.
22
+ _CACHE_TTL_SECONDS = 30
23
+ _CACHE_MAX_ENTRIES = 128
24
+ _cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
25
+
26
+
27
+ def _cache_get(key: str) -> list[dict[str, Any]] | None:
28
+ entry = _cache.get(key)
29
+ if entry is None:
30
+ return None
31
+ stored_at, pairs = entry
32
+ if time.monotonic() - stored_at > _CACHE_TTL_SECONDS:
33
+ _cache.pop(key, None)
34
+ return None
35
+ return pairs
36
+
37
+
38
+ def _cache_set(key: str, pairs: list[dict[str, Any]]) -> None:
39
+ if len(_cache) >= _CACHE_MAX_ENTRIES and key not in _cache:
40
+ oldest_key = min(_cache, key=lambda existing: _cache[existing][0])
41
+ _cache.pop(oldest_key, None)
42
+ _cache[key] = (time.monotonic(), pairs)
43
+
44
+
45
+ async def _get(path: str, *, params: dict[str, Any] | None = None) -> Any:
46
+ client = get_client()
47
+ response = await client.get(f"{DEXSCREENER_BASE_URL}{path}", params=params)
48
+ if response.status_code != 200:
49
+ raise ProviderError("dexscreener", f"HTTP {response.status_code}: {response.text[:300]}")
50
+ try:
51
+ return response.json()
52
+ except ValueError as exc:
53
+ raise ProviderError("dexscreener", "Unexpected non-JSON response from DexScreener.") from exc
54
+
55
+
56
+ def _extract_pairs(data: Any) -> list[dict[str, Any]]:
57
+ if isinstance(data, dict):
58
+ pairs = data.get("pairs")
59
+ return pairs if isinstance(pairs, list) else []
60
+ if isinstance(data, list):
61
+ return [item for item in data if isinstance(item, dict)]
62
+ return []
63
+
64
+
65
+ async def search_pairs(query: str) -> list[dict[str, Any]]:
66
+ """Free-text search across every chain/DEX DexScreener indexes."""
67
+ query = query.strip()
68
+ if not query:
69
+ raise ProviderError("dexscreener", "query must not be empty.")
70
+ cache_key = f"search:{query.lower()}"
71
+ cached = _cache_get(cache_key)
72
+ if cached is not None:
73
+ return cached
74
+ data = await _get("/latest/dex/search", params={"q": query})
75
+ pairs = _extract_pairs(data)
76
+ _cache_set(cache_key, pairs)
77
+ return pairs
78
+
79
+
80
+ async def get_pairs_for_token(*, chain: str, token_address: str) -> list[dict[str, Any]]:
81
+ """All pairs for a single token address on one chain, across every DEX on it."""
82
+ chain = chain.strip().lower()
83
+ token_address = token_address.strip()
84
+ if not chain:
85
+ raise ProviderError("dexscreener", "chain must not be empty.")
86
+ if not token_address:
87
+ raise ProviderError("dexscreener", "token_address must not be empty.")
88
+ cache_key = f"token:{chain}:{token_address.lower()}"
89
+ cached = _cache_get(cache_key)
90
+ if cached is not None:
91
+ return cached
92
+ data = await _get(f"/tokens/v1/{chain}/{token_address}")
93
+ pairs = _extract_pairs(data)
94
+ _cache_set(cache_key, pairs)
95
+ return pairs
96
+
97
+
98
+ def normalize_pair(pair: dict[str, Any]) -> dict[str, Any]:
99
+ """Flatten a DexScreener pair payload into the shape handed back to the agent."""
100
+ base_token = pair.get("baseToken") or {}
101
+ quote_token = pair.get("quoteToken") or {}
102
+ volume = pair.get("volume") or {}
103
+ liquidity = pair.get("liquidity") or {}
104
+ price_change = pair.get("priceChange") or {}
105
+ txns = pair.get("txns") or {}
106
+ return {
107
+ "chain_id": pair.get("chainId"),
108
+ "dex_id": pair.get("dexId"),
109
+ "pair_address": pair.get("pairAddress"),
110
+ "url": pair.get("url"),
111
+ "labels": pair.get("labels") or [],
112
+ "base_token": {
113
+ "address": base_token.get("address"),
114
+ "name": base_token.get("name"),
115
+ "symbol": base_token.get("symbol"),
116
+ },
117
+ "quote_token": {
118
+ "address": quote_token.get("address"),
119
+ "name": quote_token.get("name"),
120
+ "symbol": quote_token.get("symbol"),
121
+ },
122
+ "price_usd": pair.get("priceUsd"),
123
+ "price_native": pair.get("priceNative"),
124
+ "price_change_pct": {
125
+ "m5": price_change.get("m5"),
126
+ "h1": price_change.get("h1"),
127
+ "h6": price_change.get("h6"),
128
+ "h24": price_change.get("h24"),
129
+ },
130
+ "volume_usd": {
131
+ "m5": volume.get("m5"),
132
+ "h1": volume.get("h1"),
133
+ "h6": volume.get("h6"),
134
+ "h24": volume.get("h24"),
135
+ },
136
+ "txns": {
137
+ window: {"buys": counts.get("buys"), "sells": counts.get("sells")}
138
+ for window, counts in txns.items()
139
+ if isinstance(counts, dict)
140
+ },
141
+ "liquidity_usd": liquidity.get("usd"),
142
+ "fdv_usd": pair.get("fdv"),
143
+ "market_cap_usd": pair.get("marketCap"),
144
+ "pair_created_at": pair.get("pairCreatedAt"),
145
+ }
@@ -325,6 +325,18 @@ class AgentWalletBackend(ABC):
325
325
  ) -> dict[str, Any]:
326
326
  raise WalletBackendError(f"{self.name} does not support Uniswap swap previews.")
327
327
 
328
+ async def search_uniswap_pairs(
329
+ self,
330
+ *,
331
+ query: str | None = None,
332
+ token_address: str | None = None,
333
+ chain: str | None = None,
334
+ dex_id: str | None = None,
335
+ all_chains: bool = False,
336
+ limit: int = 10,
337
+ ) -> dict[str, Any]:
338
+ raise WalletBackendError(f"{self.name} does not support Uniswap pair search.")
339
+
328
340
  async def send_uniswap_swap(
329
341
  self,
330
342
  *,
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  from agent_wallet.config import normalize_evm_network
9
9
  from agent_wallet.providers.evm_portfolio import build_portfolio_snapshot
10
- from agent_wallet.providers import lifi
10
+ from agent_wallet.providers import dexscreener, lifi
11
11
  from agent_wallet.providers.wdk_evm_local import WdkEvmLocalClient
12
12
  from agent_wallet.wallet_layer.base import AgentWalletBackend, WalletBackendError, WalletCapabilities
13
13
 
@@ -1240,7 +1240,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1240
1240
  sign_only=self.sign_only,
1241
1241
  ),
1242
1242
  "broadcasted": True,
1243
- "confirmed": False,
1243
+ "confirmed": bool(data.get("confirmed")),
1244
1244
  }
1245
1245
 
1246
1246
  async def preview_evm_lido_operation(
@@ -1308,7 +1308,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1308
1308
  sign_only=self.sign_only,
1309
1309
  ),
1310
1310
  "broadcasted": True,
1311
- "confirmed": False,
1311
+ "confirmed": bool(data.get("confirmed")),
1312
1312
  }
1313
1313
 
1314
1314
  async def preview_evm_lido_withdrawal(
@@ -1385,7 +1385,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1385
1385
  sign_only=self.sign_only,
1386
1386
  ),
1387
1387
  "broadcasted": True,
1388
- "confirmed": False,
1388
+ "confirmed": bool(data.get("confirmed")),
1389
1389
  }
1390
1390
 
1391
1391
  async def preview_evm_morpho_vault_operation(
@@ -1480,7 +1480,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1480
1480
  sign_only=self.sign_only,
1481
1481
  ),
1482
1482
  "broadcasted": True,
1483
- "confirmed": False,
1483
+ "confirmed": bool(data.get("confirmed")),
1484
1484
  }
1485
1485
 
1486
1486
  async def preview_evm_morpho_market_operation(
@@ -1575,7 +1575,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1575
1575
  sign_only=self.sign_only,
1576
1576
  ),
1577
1577
  "broadcasted": True,
1578
- "confirmed": False,
1578
+ "confirmed": bool(data.get("confirmed")),
1579
1579
  }
1580
1580
 
1581
1581
  async def get_evm_swap_quote(
@@ -1816,7 +1816,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1816
1816
  "result": result,
1817
1817
  "chain_id": int(data.get("chainId") or 0),
1818
1818
  "broadcasted": True,
1819
- "confirmed": False,
1819
+ "confirmed": bool(data.get("confirmed")),
1820
1820
  "source": "wdk-evm-wallet",
1821
1821
  }
1822
1822
 
@@ -1920,6 +1920,43 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1920
1920
  "swap_transaction": swap_transaction,
1921
1921
  }
1922
1922
 
1923
+ async def search_uniswap_pairs(
1924
+ self,
1925
+ *,
1926
+ query: str | None = None,
1927
+ token_address: str | None = None,
1928
+ chain: str | None = None,
1929
+ dex_id: str | None = None,
1930
+ all_chains: bool = False,
1931
+ limit: int = 10,
1932
+ ) -> dict[str, Any]:
1933
+ query = query.strip() if isinstance(query, str) else None
1934
+ token_address = token_address.strip() if isinstance(token_address, str) else None
1935
+ if not query and not token_address:
1936
+ raise WalletBackendError("Either query or token_address is required.")
1937
+ resolved_chain = (chain or self.network).strip().lower()
1938
+ if token_address:
1939
+ pairs = await dexscreener.get_pairs_for_token(chain=resolved_chain, token_address=token_address)
1940
+ else:
1941
+ pairs = await dexscreener.search_pairs(query)
1942
+ if not all_chains:
1943
+ pairs = [p for p in pairs if str(p.get("chainId") or "").lower() == resolved_chain]
1944
+ if dex_id:
1945
+ wanted_dex = dex_id.strip().lower()
1946
+ pairs = [p for p in pairs if str(p.get("dexId") or "").lower() == wanted_dex]
1947
+ bounded_limit = max(1, min(int(limit or 10), 30))
1948
+ normalized = [dexscreener.normalize_pair(pair) for pair in pairs[:bounded_limit]]
1949
+ return {
1950
+ "chain": None if all_chains else resolved_chain,
1951
+ "all_chains": all_chains,
1952
+ "query": query,
1953
+ "token_address": token_address,
1954
+ "dex_id": dex_id,
1955
+ "count": len(normalized),
1956
+ "pairs": normalized,
1957
+ "source": "dexscreener",
1958
+ }
1959
+
1923
1960
  async def send_uniswap_swap(
1924
1961
  self,
1925
1962
  *,
@@ -1987,7 +2024,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
1987
2024
  "chain_id": int(data.get("chainId") or 0),
1988
2025
  "broadcasted": bool(result.get("hash")),
1989
2026
  "order_submitted": order_id is not None,
1990
- "confirmed": False,
2027
+ "confirmed": bool(data.get("confirmed")),
1991
2028
  "source": str(data.get("source") or "wdk-evm-wallet"),
1992
2029
  }
1993
2030
 
@@ -2102,7 +2139,7 @@ class WdkEvmLocalWalletBackend(AgentWalletBackend):
2102
2139
  "reset_allowance_hash": result.get("resetAllowanceHash"),
2103
2140
  "result": result,
2104
2141
  "broadcasted": True,
2105
- "confirmed": False,
2142
+ "confirmed": bool(data.get("confirmed")),
2106
2143
  }
2107
2144
 
2108
2145
  async def preview_evm_native_transfer(
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Plugin-friendly wallet backend for OpenClaw agents with safe wallet tools and runtime instructions across Solana, local BTC, and local EVM.",
5
- "version": "0.1.84",
5
+ "version": "0.1.89",
6
6
  "skills": ["skills/wallet-operator"],
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.84"
7
+ version = "0.1.89"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
3
  "displayName": "Agent Wallet",
4
- "version": "0.1.84",
4
+ "version": "0.1.89",
5
5
  "description": "Claude Code bridge for the existing AgentLayer wallet runtime. Connects to Solana, Bitcoin, and EVM wallets without creating a new one.",
6
6
  "author": {
7
7
  "name": "AgentLayer"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
- "version": "0.1.84",
3
+ "version": "0.1.89",
4
4
  "description": "Codex plugin bridge for the AgentLayer wallet runtime.",
5
5
  "author": {
6
6
  "name": "AgentLayer"
@@ -1,5 +1,5 @@
1
1
  name: agent-wallet
2
- version: 0.1.84
2
+ version: 0.1.89
3
3
  description: Thin Hermes Agent bridge to the existing AgentLayer/OpenClaw wallet backend
4
4
  provides_tools:
5
5
  - agent_wallet_tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.84",
3
+ "version": "0.1.89",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-btc-wallet",
3
- "version": "0.1.84",
3
+ "version": "0.1.89",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate BTC-only wallet service built on Tether WDK.",
@@ -29,5 +29,7 @@ UNISWAP_API_KEY=
29
29
  UNISWAP_ROUTER_VERSION=2.0
30
30
  # Optional per-network override; each value must exist in the wallet's reviewed
31
31
  # execution profile and match the provider-gateway's per-chain configuration.
32
- UNISWAP_ROUTER_VERSION_BY_NETWORK={"ethereum":"2.0","base":"2.0","robinhood":"2.0"}
32
+ # Robinhood Chain has no Universal Router 2.0 deployment (Uniswap defaults it
33
+ # to 2.1.1); requesting 2.0 makes the Trading API 404 every pair on that chain.
34
+ UNISWAP_ROUTER_VERSION_BY_NETWORK={"ethereum":"2.0","base":"2.0","robinhood":"2.1.1"}
33
35
  UNISWAP_DEFAULT_SLIPPAGE_BPS=300
@@ -188,6 +188,7 @@ Environment variables:
188
188
  - `UNISWAP_API_KEY`
189
189
  - `UNISWAP_TRADING_API_BASE_URL`
190
190
  - `UNISWAP_ROUTER_VERSION`
191
+ - `UNISWAP_ROUTER_VERSION_BY_NETWORK`
191
192
  - `UNISWAP_DEFAULT_SLIPPAGE_BPS`
192
193
 
193
194
  Morpho read-only support:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-evm-wallet",
3
- "version": "0.1.84",
3
+ "version": "0.1.89",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate EVM wallet service built on Tether WDK.",
@@ -57,6 +57,12 @@ const DEFAULT_NETWORK_PROFILES = {
57
57
  },
58
58
  };
59
59
 
60
+ // Robinhood Chain uses the Universal Router 2.1.1 deployment. Keep this
61
+ // network-specific default separate from the legacy global 2.0 setting so
62
+ // Ethereum and Base retain their existing routers while Robinhood can quote
63
+ // and execute without a local environment override.
64
+ const DEFAULT_UNISWAP_ROUTER_VERSIONS_BY_NETWORK = { robinhood: "2.1.1" };
65
+
60
66
  const SUPPORTED_GATEWAY_PROVIDERS = new Set(["auto", "shared", "alchemy"]);
61
67
 
62
68
  function parseProviderMode(value, fallback = "public") {
@@ -192,7 +198,7 @@ function normalizeNetworkKey(value) {
192
198
  function parseUniswapRouterVersionsByNetwork(value) {
193
199
  const raw = String(value ?? "").trim();
194
200
  if (!raw) {
195
- return {};
201
+ return { ...DEFAULT_UNISWAP_ROUTER_VERSIONS_BY_NETWORK };
196
202
  }
197
203
  let parsed;
198
204
  try {
@@ -215,7 +221,7 @@ function parseUniswapRouterVersionsByNetwork(value) {
215
221
  }
216
222
  normalized[networkKey] = normalizedVersion;
217
223
  }
218
- return normalized;
224
+ return { ...DEFAULT_UNISWAP_ROUTER_VERSIONS_BY_NETWORK, ...normalized };
219
225
  }
220
226
 
221
227
  function joinUrl(base, pathname) {
@@ -37,12 +37,21 @@ function normalizeErrorCode(errorCode, pathname, message) {
37
37
  code === "aave_quote_changed" ||
38
38
  code === "aave_approval_required" ||
39
39
  code === "aave_fee_unavailable" ||
40
+ code === "aave_operation_reverted" ||
41
+ code === "aave_operation_confirmation_timeout" ||
40
42
  code === "aave_cleanup_failed" ||
41
43
  code === "morpho_api_failed" ||
42
44
  code === "morpho_quote_changed" ||
43
45
  code === "morpho_requirements_unresolved" ||
44
46
  code === "morpho_fee_unavailable" ||
45
47
  code === "morpho_cleanup_failed" ||
48
+ code === "defi_gas_estimate_unavailable" ||
49
+ code === "lido_operation_reverted" ||
50
+ code === "lido_operation_confirmation_timeout" ||
51
+ code === "lido_withdrawal_reverted" ||
52
+ code === "lido_withdrawal_confirmation_timeout" ||
53
+ code === "swap_reverted" ||
54
+ code === "swap_confirmation_timeout" ||
46
55
  code === "token_transfer_failed" ||
47
56
  code === "fee_limit_exceeded" ||
48
57
  code === "token_read_failed" ||
@@ -138,11 +147,20 @@ function errorStatusCode(errorCode, fallback = 400) {
138
147
  errorCode === "swap_cleanup_failed" ||
139
148
  errorCode === "aave_approval_required" ||
140
149
  errorCode === "aave_fee_unavailable" ||
150
+ errorCode === "aave_operation_reverted" ||
151
+ errorCode === "aave_operation_confirmation_timeout" ||
141
152
  errorCode === "aave_cleanup_failed" ||
142
153
  errorCode === "morpho_api_failed" ||
143
154
  errorCode === "morpho_requirements_unresolved" ||
144
155
  errorCode === "morpho_fee_unavailable" ||
145
156
  errorCode === "morpho_cleanup_failed" ||
157
+ errorCode === "defi_gas_estimate_unavailable" ||
158
+ errorCode === "lido_operation_reverted" ||
159
+ errorCode === "lido_operation_confirmation_timeout" ||
160
+ errorCode === "lido_withdrawal_reverted" ||
161
+ errorCode === "lido_withdrawal_confirmation_timeout" ||
162
+ errorCode === "swap_reverted" ||
163
+ errorCode === "swap_confirmation_timeout" ||
146
164
  errorCode === "token_transfer_failed" ||
147
165
  errorCode === "fee_limit_exceeded" ||
148
166
  errorCode === "uniswap_api_key_missing"
@@ -41,12 +41,17 @@ const UNISWAP_EXECUTION_PROFILES = {
41
41
  },
42
42
  robinhood: {
43
43
  chainId: 4663,
44
- universalRouters: { "2.0": "0x8876789976decbfcbbbe364623c63652db8c0904" },
44
+ // Robinhood Chain has no Universal Router 2.0 deployment - Uniswap
45
+ // defaults it (like Ink) to 2.1.1, and requesting "2.0" makes the Trading
46
+ // API return a blanket "No quotes available" for every pair on this
47
+ // chain. See https://blog.uniswap.org/robinhood-chain-is-live. Same
48
+ // contract address as before; only the version label changes.
49
+ universalRouters: { "2.1.1": "0x8876789976decbfcbbbe364623c63652db8c0904" },
45
50
  wrappedNative: "0x0bd7d308f8e1639fab988df18a8011f41eacad73",
46
- // Robinhood's official deployment exposes the V3 stack. Asking the Trading
47
- // API for undeployed V2/V4 routes can make an otherwise valid V3-only pair
48
- // appear unavailable, so keep the protocol set chain-specific.
49
- ammProtocols: ["V3"],
51
+ // Full protocol stack like ethereum/base - the earlier V3-only filter
52
+ // wasn't the actual blocker (see router version note above) but there's
53
+ // no reason to hide V2/V4 routes from the Trading API either.
54
+ ammProtocols: ["V2", "V3", "V4"],
50
55
  v3DirectFallback: {
51
56
  quoterV2: "0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7",
52
57
  swapRouter02: "0xcaf681a66d020601342297493863e78c959e5cb2",
@@ -155,8 +160,20 @@ const MORPHO_MAX_LIST_LIMIT = 500;
155
160
  // two minutes is below the indexer's own aggregation noise.
156
161
  const MORPHO_DISCOVERY_CACHE_TTL_MS = 120_000;
157
162
  const MORPHO_DISCOVERY_CACHE_MAX_ENTRIES = 64;
163
+ // DeFi routes can take a different on-chain branch between estimate time and
164
+ // inclusion. Estimate exact final calldata immediately before signing and use
165
+ // an explicit headroom rather than relying on the provider/WDK default.
166
+ const DEFI_GAS_BUFFER_BPS = 13_000n;
167
+ const MORPHO_GAS_BUFFER_BPS = DEFI_GAS_BUFFER_BPS;
168
+ const BPS_DENOMINATOR = 10_000n;
158
169
  const morphoDiscoveryCache = new Map();
159
170
 
171
+ function applyGasBuffer(value, bufferBps = MORPHO_GAS_BUFFER_BPS) {
172
+ const normalizedValue = BigInt(value);
173
+ const normalizedBufferBps = BigInt(bufferBps);
174
+ return (normalizedValue * normalizedBufferBps + BPS_DENOMINATOR - 1n) / BPS_DENOMINATOR;
175
+ }
176
+
160
177
  function morphoDiscoveryCacheGet(key) {
161
178
  const entry = morphoDiscoveryCache.get(key);
162
179
  if (!entry) {
@@ -2512,12 +2529,20 @@ export class WdkEvmWalletService {
2512
2529
  const protocol = this.#createMorphoProtocol(account, runtimeConfig, request);
2513
2530
  let result;
2514
2531
  try {
2515
- result = await protocol[this.#getMorphoOperationMethods(request).sendMethod](
2516
- this.#buildMorphoOperationOptions(request)
2517
- );
2532
+ result = await this.#sendMorphoProtocolOperation({
2533
+ account,
2534
+ runtimeConfig,
2535
+ protocol,
2536
+ request,
2537
+ });
2518
2538
  } finally {
2519
2539
  await maybeDispose(protocol);
2520
2540
  }
2541
+ await this.#waitForTransactionReceipt(runtimeConfig, result.hash, {
2542
+ operationLabel: "Morpho operation",
2543
+ failureCode: "morpho_operation_reverted",
2544
+ timeoutCode: "morpho_operation_confirmation_timeout",
2545
+ });
2521
2546
  const resultFee = BigInt(result?.fee || finalPlan.operationFee || 0);
2522
2547
  const totalFee = requirementExecution.totalFee + resultFee;
2523
2548
  return {
@@ -2543,6 +2568,7 @@ export class WdkEvmWalletService {
2543
2568
  requirementsFee: requirementExecution.totalFee.toString(),
2544
2569
  requirements: requirementExecution.transactions,
2545
2570
  },
2571
+ confirmed: true,
2546
2572
  };
2547
2573
  } catch (error) {
2548
2574
  const cleanup = await this.#restoreMorphoRequirementsAfterFailedOperation({
@@ -2689,12 +2715,20 @@ export class WdkEvmWalletService {
2689
2715
  const protocol = this.#createMorphoProtocol(account, runtimeConfig, request);
2690
2716
  let result;
2691
2717
  try {
2692
- result = await protocol[this.#getMorphoOperationMethods(request).sendMethod](
2693
- this.#buildMorphoOperationOptions(request)
2694
- );
2718
+ result = await this.#sendMorphoProtocolOperation({
2719
+ account,
2720
+ runtimeConfig,
2721
+ protocol,
2722
+ request,
2723
+ });
2695
2724
  } finally {
2696
2725
  await maybeDispose(protocol);
2697
2726
  }
2727
+ await this.#waitForTransactionReceipt(runtimeConfig, result.hash, {
2728
+ operationLabel: "Morpho operation",
2729
+ failureCode: "morpho_operation_reverted",
2730
+ timeoutCode: "morpho_operation_confirmation_timeout",
2731
+ });
2698
2732
  const resultFee = BigInt(result?.fee || finalPlan.operationFee || 0);
2699
2733
  const totalFee = requirementExecution.totalFee + resultFee;
2700
2734
  return {
@@ -2720,6 +2754,7 @@ export class WdkEvmWalletService {
2720
2754
  requirementsFee: requirementExecution.totalFee.toString(),
2721
2755
  requirements: requirementExecution.transactions,
2722
2756
  },
2757
+ confirmed: true,
2723
2758
  };
2724
2759
  } catch (error) {
2725
2760
  const cleanup = await this.#restoreMorphoRequirementsAfterFailedOperation({
@@ -2856,13 +2891,25 @@ export class WdkEvmWalletService {
2856
2891
  const protocol = new AaveProtocolEvm(account);
2857
2892
  let result;
2858
2893
  try {
2859
- result = await protocol[request.operation]({
2860
- token: request.token,
2861
- amount: request.amount,
2894
+ result = await this.#sendBufferedDefiProtocolOperation({
2895
+ account,
2896
+ runtimeConfig,
2897
+ operationLabel: `Aave ${request.operation}`,
2898
+ operation: request.operation,
2899
+ invoke: () =>
2900
+ protocol[request.operation]({
2901
+ token: request.token,
2902
+ amount: request.amount,
2903
+ }),
2862
2904
  });
2863
2905
  } finally {
2864
2906
  await maybeDispose(protocol);
2865
2907
  }
2908
+ await this.#waitForTransactionReceipt(runtimeConfig, result.hash, {
2909
+ operationLabel: "Aave operation",
2910
+ failureCode: "aave_operation_reverted",
2911
+ timeoutCode: "aave_operation_confirmation_timeout",
2912
+ });
2866
2913
  const resultFee = BigInt(result?.fee || 0);
2867
2914
  const totalFee = approvalExecution.totalFee + resultFee;
2868
2915
  return {
@@ -2891,6 +2938,7 @@ export class WdkEvmWalletService {
2891
2938
  ? { resetAllowanceHash: approvalExecution.resetAllowanceHash }
2892
2939
  : {}),
2893
2940
  },
2941
+ confirmed: true,
2894
2942
  };
2895
2943
  } catch (error) {
2896
2944
  const cleanup = await this.#restoreAllowanceAfterFailedAaveOperation({
@@ -3172,7 +3220,19 @@ export class WdkEvmWalletService {
3172
3220
  );
3173
3221
  }
3174
3222
 
3175
- const result = await account.sendTransaction(finalPlan.operationTx);
3223
+ const result = await this.#sendBufferedDefiTransaction({
3224
+ account,
3225
+ runtimeConfig,
3226
+ from: address,
3227
+ tx: finalPlan.operationTx,
3228
+ operationLabel: `Lido ${request.operation}`,
3229
+ operation: request.operation,
3230
+ });
3231
+ await this.#waitForTransactionReceipt(runtimeConfig, result.hash, {
3232
+ operationLabel: "Lido operation",
3233
+ failureCode: "lido_operation_reverted",
3234
+ timeoutCode: "lido_operation_confirmation_timeout",
3235
+ });
3176
3236
  const resultFee = BigInt(result?.fee || finalPlan.operationFee || 0);
3177
3237
  const totalFee = approvalExecution.totalFee + resultFee;
3178
3238
  return {
@@ -3201,6 +3261,7 @@ export class WdkEvmWalletService {
3201
3261
  ? { resetAllowanceHash: approvalExecution.resetAllowanceHash }
3202
3262
  : {}),
3203
3263
  },
3264
+ confirmed: true,
3204
3265
  };
3205
3266
  } catch (error) {
3206
3267
  const cleanup = await this.#restoreAllowanceAfterFailedLidoOperation({
@@ -3323,7 +3384,19 @@ export class WdkEvmWalletService {
3323
3384
  );
3324
3385
  }
3325
3386
 
3326
- const result = await account.sendTransaction(finalPlan.operationTx);
3387
+ const result = await this.#sendBufferedDefiTransaction({
3388
+ account,
3389
+ runtimeConfig,
3390
+ from: address,
3391
+ tx: finalPlan.operationTx,
3392
+ operationLabel: `Lido ${request.operation}`,
3393
+ operation: request.operation,
3394
+ });
3395
+ await this.#waitForTransactionReceipt(runtimeConfig, result.hash, {
3396
+ operationLabel: "Lido withdrawal",
3397
+ failureCode: "lido_withdrawal_reverted",
3398
+ timeoutCode: "lido_withdrawal_confirmation_timeout",
3399
+ });
3327
3400
  const resultFee = BigInt(result?.fee || finalPlan.operationFee || 0);
3328
3401
  const totalFee = approvalExecution.totalFee + resultFee;
3329
3402
  return {
@@ -3352,6 +3425,7 @@ export class WdkEvmWalletService {
3352
3425
  ? { resetAllowanceHash: approvalExecution.resetAllowanceHash }
3353
3426
  : {}),
3354
3427
  },
3428
+ confirmed: true,
3355
3429
  };
3356
3430
  } catch (error) {
3357
3431
  const cleanup = await this.#restoreAllowanceAfterFailedLidoWithdrawal({
@@ -3584,12 +3658,23 @@ export class WdkEvmWalletService {
3584
3658
  })
3585
3659
  : finalPlan.simulation;
3586
3660
  this.#assertSimulationSucceeded(effectiveSimulation);
3587
- const { hash } = await account.sendTransaction(finalPlan.swapTx);
3588
- const totalFee = approvalExecution.totalFee + finalPlan.swapFee;
3661
+ const swapResult = await this.#sendBufferedDefiTransaction({
3662
+ account,
3663
+ runtimeConfig,
3664
+ from: address,
3665
+ tx: finalPlan.swapTx,
3666
+ operationLabel: "Velora swap",
3667
+ });
3668
+ await this.#waitForTransactionReceipt(runtimeConfig, swapResult.hash, {
3669
+ operationLabel: "Velora swap",
3670
+ failureCode: "swap_reverted",
3671
+ timeoutCode: "swap_confirmation_timeout",
3672
+ });
3673
+ const totalFee = approvalExecution.totalFee + BigInt(swapResult.fee);
3589
3674
  const result = {
3590
- hash,
3675
+ ...swapResult,
3591
3676
  fee: totalFee.toString(),
3592
- swapFee: finalPlan.swapFee.toString(),
3677
+ swapFee: swapResult.fee,
3593
3678
  approvalFee: approvalExecution.totalFee.toString(),
3594
3679
  tokenInAmount: finalPlan.tokenInAmount.toString(),
3595
3680
  tokenOutAmount: finalPlan.tokenOutAmount.toString(),
@@ -3630,6 +3715,7 @@ export class WdkEvmWalletService {
3630
3715
  simulation: effectiveSimulation,
3631
3716
  swapTransaction: finalPlan.swapTransaction,
3632
3717
  result,
3718
+ confirmed: true,
3633
3719
  source: "wdk-protocol-swap-velora-evm",
3634
3720
  };
3635
3721
  } catch (error) {
@@ -3740,12 +3826,23 @@ export class WdkEvmWalletService {
3740
3826
  : finalPlan.simulation;
3741
3827
  this.#assertSimulationSucceeded(effectiveSimulation);
3742
3828
 
3743
- const { hash } = await account.sendTransaction(finalPlan.swapTx);
3744
- const totalFee = approvalExecution.totalFee + finalPlan.swapFee;
3829
+ const swapResult = await this.#sendBufferedDefiTransaction({
3830
+ account,
3831
+ runtimeConfig,
3832
+ from: sourceAddress,
3833
+ tx: finalPlan.swapTx,
3834
+ operationLabel: "LI.FI swap",
3835
+ });
3836
+ await this.#waitForTransactionReceipt(runtimeConfig, swapResult.hash, {
3837
+ operationLabel: "LI.FI swap",
3838
+ failureCode: "swap_reverted",
3839
+ timeoutCode: "swap_confirmation_timeout",
3840
+ });
3841
+ const totalFee = approvalExecution.totalFee + BigInt(swapResult.fee);
3745
3842
  const result = {
3746
- hash,
3843
+ ...swapResult,
3747
3844
  fee: totalFee.toString(),
3748
- swapFee: finalPlan.swapFee.toString(),
3845
+ swapFee: swapResult.fee,
3749
3846
  approvalFee: approvalExecution.totalFee.toString(),
3750
3847
  tokenInAmount: finalPlan.tokenInAmount.toString(),
3751
3848
  tokenOutAmount: finalPlan.tokenOutAmount.toString(),
@@ -3772,6 +3869,7 @@ export class WdkEvmWalletService {
3772
3869
  },
3773
3870
  }),
3774
3871
  result,
3872
+ confirmed: true,
3775
3873
  };
3776
3874
  } catch (error) {
3777
3875
  const cleanup = await this.#restoreAllowanceAfterFailedSwap({
@@ -3967,14 +4065,25 @@ export class WdkEvmWalletService {
3967
4065
  });
3968
4066
  this.#assertSimulationSucceeded(simulation);
3969
4067
 
3970
- const { hash } = await account.sendTransaction(swapTx);
4068
+ const swapResult = await this.#sendBufferedDefiTransaction({
4069
+ account,
4070
+ runtimeConfig,
4071
+ from: address,
4072
+ tx: swapTx,
4073
+ operationLabel: "Uniswap swap",
4074
+ });
4075
+ await this.#waitForTransactionReceipt(runtimeConfig, swapResult.hash, {
4076
+ operationLabel: "Uniswap swap",
4077
+ failureCode: "swap_reverted",
4078
+ timeoutCode: "swap_confirmation_timeout",
4079
+ });
3971
4080
  swapTransaction = {
3972
4081
  to: swapTx.to,
3973
4082
  value: swapTx.value.toString(),
3974
4083
  dataHash: sha256Hex(swapTx.data),
3975
4084
  };
3976
4085
  result = {
3977
- hash,
4086
+ ...swapResult,
3978
4087
  approvalFee: approvalExecution.totalFee.toString(),
3979
4088
  tokenInAmount: finalPlan.tokenInAmount.toString(),
3980
4089
  tokenOutAmount: finalPlan.tokenOutAmount.toString(),
@@ -4001,6 +4110,7 @@ export class WdkEvmWalletService {
4001
4110
  simulation,
4002
4111
  swapTransaction,
4003
4112
  result,
4113
+ confirmed: Boolean(result.hash),
4004
4114
  };
4005
4115
  } catch (error) {
4006
4116
  const cleanup = await this.#restoreAllowanceAfterFailedSwap({
@@ -4536,6 +4646,150 @@ export class WdkEvmWalletService {
4536
4646
  return options;
4537
4647
  }
4538
4648
 
4649
+ async #sendMorphoProtocolOperation({ account, runtimeConfig, protocol, request }) {
4650
+ const methods = this.#getMorphoOperationMethods(request);
4651
+ const originalSendTransaction = account.sendTransaction;
4652
+ if (typeof originalSendTransaction !== "function") {
4653
+ throw new Error("Morpho operation requires an EVM account with sendTransaction().");
4654
+ }
4655
+ const hadOwnSendTransaction = Object.prototype.hasOwnProperty.call(account, "sendTransaction");
4656
+
4657
+ // The protocol constructs the final calldata immediately before sending it.
4658
+ // Intercept that one protocol-scoped send so the estimate and padded limit
4659
+ // are based on the exact transaction, rather than a stale preview.
4660
+ account.sendTransaction = async (tx) => {
4661
+ const gas = await this.#prepareMorphoGasLimit({
4662
+ runtimeConfig,
4663
+ from: await account.getAddress(),
4664
+ tx,
4665
+ operation: request.operation,
4666
+ });
4667
+ const result = await originalSendTransaction.call(account, {
4668
+ ...tx,
4669
+ gasLimit: gas.gasLimit,
4670
+ });
4671
+ return {
4672
+ ...result,
4673
+ fee: gas.maximumFee,
4674
+ gasEstimate: gas.gasEstimate.toString(),
4675
+ gasLimit: gas.gasLimit.toString(),
4676
+ gasBufferBps: MORPHO_GAS_BUFFER_BPS.toString(),
4677
+ };
4678
+ };
4679
+
4680
+ try {
4681
+ return await protocol[methods.sendMethod](this.#buildMorphoOperationOptions(request));
4682
+ } finally {
4683
+ if (hadOwnSendTransaction) {
4684
+ account.sendTransaction = originalSendTransaction;
4685
+ } else {
4686
+ delete account.sendTransaction;
4687
+ }
4688
+ }
4689
+ }
4690
+
4691
+ async #prepareMorphoGasLimit({ runtimeConfig, from, tx, operation }) {
4692
+ return this.#prepareBufferedDefiGasLimit({
4693
+ runtimeConfig,
4694
+ from,
4695
+ tx,
4696
+ operationLabel: `morpho ${operation}`,
4697
+ unavailableCode: "morpho_gas_estimate_unavailable",
4698
+ unavailableMessage: "Morpho gas estimate was empty. Generate a new quote before sending.",
4699
+ });
4700
+ }
4701
+
4702
+ async #prepareBufferedDefiGasLimit({
4703
+ runtimeConfig,
4704
+ from,
4705
+ tx,
4706
+ operationLabel,
4707
+ unavailableCode = "defi_gas_estimate_unavailable",
4708
+ unavailableMessage = "DeFi gas estimate was empty. Generate a new quote before sending.",
4709
+ }) {
4710
+ const gasEstimateHex = await rpcRequest(runtimeConfig.providerUrl, "eth_estimateGas", [
4711
+ {
4712
+ from: normalizeAddress(from, "from"),
4713
+ to: normalizeAddress(String(tx.to || ""), "to"),
4714
+ data: assertNonEmptyString(String(tx.data || ""), "data"),
4715
+ value: toRpcHex(tx.value || 0),
4716
+ },
4717
+ ]);
4718
+ const gasEstimate = BigInt(gasEstimateHex || "0x0");
4719
+ if (gasEstimate <= 0n) {
4720
+ throw createTaggedError(unavailableMessage, unavailableCode, { operation: operationLabel });
4721
+ }
4722
+ const gasLimit = applyGasBuffer(gasEstimate, DEFI_GAS_BUFFER_BPS);
4723
+ const effectiveFeePerGas = await this.#getEffectiveGasPrice(runtimeConfig);
4724
+ const maximumFee = gasLimit * effectiveFeePerGas;
4725
+ this.#assertMaxFee(runtimeConfig, maximumFee, operationLabel);
4726
+ return { gasEstimate, gasLimit, maximumFee };
4727
+ }
4728
+
4729
+ async #sendBufferedDefiTransaction({ account, runtimeConfig, from, tx, operationLabel }) {
4730
+ return this.#sendBufferedDefiTransactionWithSender({
4731
+ sendTransaction: (preparedTx) => account.sendTransaction(preparedTx),
4732
+ runtimeConfig,
4733
+ from,
4734
+ tx,
4735
+ operationLabel,
4736
+ });
4737
+ }
4738
+
4739
+ async #sendBufferedDefiTransactionWithSender({
4740
+ sendTransaction,
4741
+ runtimeConfig,
4742
+ from,
4743
+ tx,
4744
+ operationLabel,
4745
+ }) {
4746
+ const gas = await this.#prepareBufferedDefiGasLimit({
4747
+ runtimeConfig,
4748
+ from,
4749
+ tx,
4750
+ operationLabel,
4751
+ });
4752
+ const result = await sendTransaction({ ...tx, gasLimit: gas.gasLimit });
4753
+ return {
4754
+ ...result,
4755
+ fee: gas.maximumFee.toString(),
4756
+ gasEstimate: gas.gasEstimate.toString(),
4757
+ gasLimit: gas.gasLimit.toString(),
4758
+ gasBufferBps: DEFI_GAS_BUFFER_BPS.toString(),
4759
+ };
4760
+ }
4761
+
4762
+ async #sendBufferedDefiProtocolOperation({
4763
+ account,
4764
+ runtimeConfig,
4765
+ operationLabel,
4766
+ operation,
4767
+ invoke,
4768
+ }) {
4769
+ const originalSendTransaction = account.sendTransaction;
4770
+ if (typeof originalSendTransaction !== "function") {
4771
+ throw new Error(`${operationLabel} requires an EVM account with sendTransaction().`);
4772
+ }
4773
+ const hadOwnSendTransaction = Object.prototype.hasOwnProperty.call(account, "sendTransaction");
4774
+ account.sendTransaction = async (tx) =>
4775
+ this.#sendBufferedDefiTransactionWithSender({
4776
+ sendTransaction: (preparedTx) => originalSendTransaction.call(account, preparedTx),
4777
+ runtimeConfig,
4778
+ from: await account.getAddress(),
4779
+ tx,
4780
+ operationLabel: `${operationLabel} ${operation}`,
4781
+ });
4782
+ try {
4783
+ return await invoke();
4784
+ } finally {
4785
+ if (hadOwnSendTransaction) {
4786
+ account.sendTransaction = originalSendTransaction;
4787
+ } else {
4788
+ delete account.sendTransaction;
4789
+ }
4790
+ }
4791
+ }
4792
+
4539
4793
  async #buildMorphoOperationPlan({
4540
4794
  account,
4541
4795
  runtimeConfig,
@@ -4736,7 +4990,7 @@ export class WdkEvmWalletService {
4736
4990
  try {
4737
4991
  const quote = await protocol[methods.quoteMethod](operationOptions);
4738
4992
  return {
4739
- fee: BigInt(quote?.fee || 0),
4993
+ fee: applyGasBuffer(BigInt(quote?.fee || 0), MORPHO_GAS_BUFFER_BPS),
4740
4994
  error: null,
4741
4995
  };
4742
4996
  } catch (error) {
@@ -4833,11 +5087,23 @@ export class WdkEvmWalletService {
4833
5087
  for (let index = 0; index < plan.requirements.transactions.length; index += 1) {
4834
5088
  const requirementTx = plan.requirements.transactions[index];
4835
5089
  const step = plan.requirements.steps[index];
4836
- const result = await account.sendTransaction({
5090
+ const tx = {
4837
5091
  to: requirementTx.to,
4838
5092
  value: requirementTx.value ?? 0n,
4839
5093
  data: requirementTx.data,
4840
- });
5094
+ };
5095
+ // ERC-20 approval writes intentionally retain the narrow WDK path. A
5096
+ // Morpho authorization is a protocol write, so give it the same buffer
5097
+ // as the potentially stateful final operation.
5098
+ const result = step?.type === "authorization"
5099
+ ? await this.#sendBufferedDefiTransaction({
5100
+ account,
5101
+ runtimeConfig,
5102
+ from: await account.getAddress(),
5103
+ tx,
5104
+ operationLabel: "Morpho authorization",
5105
+ })
5106
+ : await account.sendTransaction(tx);
4841
5107
  const fee = BigInt(result?.fee || 0);
4842
5108
  totalFee += fee;
4843
5109
  transactions.push({
@@ -4913,13 +5179,19 @@ export class WdkEvmWalletService {
4913
5179
  }
4914
5180
 
4915
5181
  for (const context of requirementExecution.authorizationContexts || []) {
4916
- const result = await account.sendTransaction({
5182
+ const result = await this.#sendBufferedDefiTransaction({
5183
+ account,
5184
+ runtimeConfig,
5185
+ from: await account.getAddress(),
5186
+ operationLabel: "Morpho authorization cleanup",
5187
+ tx: {
4917
5188
  to: context.contractAddress,
4918
5189
  value: 0n,
4919
5190
  data: MORPHO_AUTHORIZATION_INTERFACE.encodeFunctionData("setAuthorization", [
4920
5191
  context.authorized,
4921
5192
  false,
4922
5193
  ]),
5194
+ },
4923
5195
  });
4924
5196
  cleanup.authorizations.push({
4925
5197
  contractAddress: context.contractAddress,
@@ -8030,13 +8302,21 @@ export class WdkEvmWalletService {
8030
8302
  }
8031
8303
  }
8032
8304
 
8033
- async #waitForTransactionReceipt(runtimeConfig, txHash) {
8305
+ async #waitForTransactionReceipt(
8306
+ runtimeConfig,
8307
+ txHash,
8308
+ {
8309
+ operationLabel = "Approval transaction",
8310
+ failureCode = "swap_approval_failed",
8311
+ timeoutCode = "swap_approval_timeout",
8312
+ } = {}
8313
+ ) {
8034
8314
  for (let attempt = 0; attempt < 30; attempt += 1) {
8035
8315
  const receipt = await rpcRequest(runtimeConfig.providerUrl, "eth_getTransactionReceipt", [txHash]);
8036
8316
  if (receipt) {
8037
8317
  const status = String(receipt.status || "").toLowerCase();
8038
8318
  if (status === "0x0") {
8039
- throw createTaggedError("Approval transaction reverted onchain.", "swap_approval_failed", {
8319
+ throw createTaggedError(`${operationLabel} reverted onchain.`, failureCode, {
8040
8320
  txHash,
8041
8321
  network: runtimeConfig.network,
8042
8322
  });
@@ -8046,8 +8326,8 @@ export class WdkEvmWalletService {
8046
8326
  await new Promise((resolve) => setTimeout(resolve, 1000));
8047
8327
  }
8048
8328
  throw createTaggedError(
8049
- "Timed out waiting for approval transaction confirmation.",
8050
- "swap_approval_timeout",
8329
+ `Timed out waiting for ${operationLabel.toLowerCase()} confirmation.`,
8330
+ timeoutCode,
8051
8331
  {
8052
8332
  txHash,
8053
8333
  network: runtimeConfig.network,
@@ -8057,6 +8337,8 @@ export class WdkEvmWalletService {
8057
8337
  }
8058
8338
 
8059
8339
  export const __testables = {
8340
+ applyGasBuffer,
8341
+ MORPHO_GAS_BUFFER_BPS,
8060
8342
  PERMIT2_ADDRESS,
8061
8343
  UNISWAP_SUPPORTED_CHAIN_IDS,
8062
8344
  UNISWAP_EXECUTION_PROFILES,