@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,470 @@
1
+ """EVM portfolio helpers for token discovery and USD enrichment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from decimal import Decimal, InvalidOperation
7
+ from typing import Any
8
+
9
+ from agent_wallet.config import settings
10
+ from agent_wallet.exceptions import ProviderError
11
+ from agent_wallet.http_client import get_client
12
+
13
+ COINGECKO_API_URL = "https://api.coingecko.com/api/v3"
14
+ PORTFOLIO_TOKEN_CACHE_TTL_SECONDS = 30.0
15
+ PORTFOLIO_PRICE_CACHE_TTL_SECONDS = 60.0
16
+
17
+ ALCHEMY_RPC_URLS = {
18
+ "ethereum": "https://eth-mainnet.g.alchemy.com/v2",
19
+ "base": "https://base-mainnet.g.alchemy.com/v2",
20
+ }
21
+
22
+ TOKEN_METADATA: dict[str, dict[str, dict[str, Any]]] = {
23
+ "ethereum": {
24
+ "0xdac17f958d2ee523a2206206994597c13d831ec7": {
25
+ "symbol": "USDT",
26
+ "name": "Tether USD",
27
+ "decimals": 6,
28
+ },
29
+ "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {
30
+ "symbol": "USDC",
31
+ "name": "USD Coin",
32
+ "decimals": 6,
33
+ },
34
+ "0x6b175474e89094c44da98b954eedeac495271d0f": {
35
+ "symbol": "DAI",
36
+ "name": "Dai",
37
+ "decimals": 18,
38
+ },
39
+ "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {
40
+ "symbol": "WBTC",
41
+ "name": "Wrapped BTC",
42
+ "decimals": 8,
43
+ },
44
+ "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {
45
+ "symbol": "WETH",
46
+ "name": "Wrapped Ether",
47
+ "decimals": 18,
48
+ },
49
+ "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0": {
50
+ "symbol": "wstETH",
51
+ "name": "Wrapped stETH",
52
+ "decimals": 18,
53
+ },
54
+ "0xae78736cd615f374d3085123a210448e74fc6393": {
55
+ "symbol": "rETH",
56
+ "name": "Rocket Pool ETH",
57
+ "decimals": 18,
58
+ },
59
+ "0x514910771af9ca656af840dff83e8264ecf986ca": {
60
+ "symbol": "LINK",
61
+ "name": "Chainlink",
62
+ "decimals": 18,
63
+ },
64
+ "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {
65
+ "symbol": "UNI",
66
+ "name": "Uniswap",
67
+ "decimals": 18,
68
+ },
69
+ "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {
70
+ "symbol": "AAVE",
71
+ "name": "Aave",
72
+ "decimals": 18,
73
+ },
74
+ "0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2": {
75
+ "symbol": "MKR",
76
+ "name": "Maker",
77
+ "decimals": 18,
78
+ },
79
+ "0x5a98fcbea516cf06857215779fd812ca3bef1b32": {
80
+ "symbol": "LDO",
81
+ "name": "Lido DAO",
82
+ "decimals": 18,
83
+ },
84
+ "0xd533a949740bb3306d119cc777fa900ba034cd52": {
85
+ "symbol": "CRV",
86
+ "name": "Curve DAO Token",
87
+ "decimals": 18,
88
+ },
89
+ "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {
90
+ "symbol": "SHIB",
91
+ "name": "Shiba Inu",
92
+ "decimals": 18,
93
+ },
94
+ "0x6982508145454ce325ddbe47a25d4ec3d2311933": {
95
+ "symbol": "PEPE",
96
+ "name": "Pepe",
97
+ "decimals": 18,
98
+ },
99
+ },
100
+ "base": {
101
+ "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": {
102
+ "symbol": "USDC",
103
+ "name": "USD Coin",
104
+ "decimals": 6,
105
+ },
106
+ "0x50c5725949a6f0c72e6c4a641f24049a917db0cb": {
107
+ "symbol": "DAI",
108
+ "name": "Dai",
109
+ "decimals": 18,
110
+ },
111
+ "0x4200000000000000000000000000000000000006": {
112
+ "symbol": "WETH",
113
+ "name": "Wrapped Ether",
114
+ "decimals": 18,
115
+ },
116
+ "0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22": {
117
+ "symbol": "cbETH",
118
+ "name": "Coinbase Wrapped Staked ETH",
119
+ "decimals": 18,
120
+ },
121
+ },
122
+ }
123
+
124
+ COINGECKO_IDS = {
125
+ "ETH": "ethereum",
126
+ "WETH": "ethereum",
127
+ "USDC": "usd-coin",
128
+ "USDT": "tether",
129
+ "DAI": "dai",
130
+ "WBTC": "wrapped-bitcoin",
131
+ "LINK": "chainlink",
132
+ "UNI": "uniswap",
133
+ "AAVE": "aave",
134
+ "MKR": "maker",
135
+ "LDO": "lido-dao",
136
+ "CRV": "curve-dao-token",
137
+ "SHIB": "shiba-inu",
138
+ "PEPE": "pepe",
139
+ "RETH": "rocket-pool-eth",
140
+ "CBETH": "coinbase-wrapped-staked-eth",
141
+ "WSTETH": "wrapped-steth",
142
+ }
143
+
144
+ _TOKEN_CACHE: dict[str, tuple[float, list[dict[str, Any]]]] = {}
145
+ _PRICE_CACHE: dict[str, tuple[float, float]] = {}
146
+
147
+
148
+ def _normalize_network(network: str) -> str:
149
+ normalized = str(network or "").strip().lower()
150
+ if normalized not in {"ethereum", "base"}:
151
+ raise ProviderError("evm-portfolio", f"Unsupported EVM portfolio network: {network}")
152
+ return normalized
153
+
154
+
155
+ def _cache_get_token_balances(cache_key: str) -> list[dict[str, Any]] | None:
156
+ cached = _TOKEN_CACHE.get(cache_key)
157
+ if not cached:
158
+ return None
159
+ expires_at, payload = cached
160
+ if expires_at <= time.time():
161
+ _TOKEN_CACHE.pop(cache_key, None)
162
+ return None
163
+ return payload
164
+
165
+
166
+ def _cache_set_token_balances(cache_key: str, payload: list[dict[str, Any]]) -> None:
167
+ _TOKEN_CACHE[cache_key] = (time.time() + PORTFOLIO_TOKEN_CACHE_TTL_SECONDS, payload)
168
+
169
+
170
+ def _cache_get_price(symbol: str) -> float | None:
171
+ cached = _PRICE_CACHE.get(symbol.upper())
172
+ if not cached:
173
+ return None
174
+ expires_at, price = cached
175
+ if expires_at <= time.time():
176
+ _PRICE_CACHE.pop(symbol.upper(), None)
177
+ return None
178
+ return price
179
+
180
+
181
+ def _cache_set_price(symbol: str, price: float) -> None:
182
+ _PRICE_CACHE[symbol.upper()] = (time.time() + PORTFOLIO_PRICE_CACHE_TTL_SECONDS, price)
183
+
184
+
185
+ def _format_decimal(value: Decimal | None, *, places: int | None = None) -> str | None:
186
+ if value is None:
187
+ return None
188
+ normalized = value
189
+ if places is not None:
190
+ quant = Decimal("1").scaleb(-places)
191
+ normalized = value.quantize(quant)
192
+ text = format(normalized.normalize(), "f")
193
+ if "." in text:
194
+ text = text.rstrip("0").rstrip(".")
195
+ return text or "0"
196
+
197
+
198
+ def _to_decimal(value: Any) -> Decimal | None:
199
+ if value is None:
200
+ return None
201
+ try:
202
+ return Decimal(str(value))
203
+ except (InvalidOperation, ValueError):
204
+ return None
205
+
206
+
207
+ async def _gateway_or_alchemy_rpc_call(network: str, method: str, params: list[Any]) -> dict[str, Any]:
208
+ client = get_client()
209
+ payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
210
+ gateway_url = str(settings.provider_gateway_url or "").strip()
211
+ bearer = str(settings.provider_gateway_bearer_token or "").strip()
212
+ gateway_error: Exception | None = None
213
+
214
+ if gateway_url:
215
+ try:
216
+ response = await client.post(
217
+ f"{gateway_url.rstrip('/')}/v1/evm/rpc/{network}?provider=alchemy",
218
+ json=payload,
219
+ headers={"Authorization": f"Bearer {bearer}"} if bearer else None,
220
+ )
221
+ if response.status_code == 200:
222
+ data = response.json()
223
+ if isinstance(data, dict) and "error" not in data:
224
+ return data
225
+ gateway_error = ProviderError("evm-portfolio", f"Gateway RPC error: {data}")
226
+ else:
227
+ gateway_error = ProviderError(
228
+ "evm-portfolio",
229
+ f"Gateway returned HTTP {response.status_code} for {method}",
230
+ )
231
+ except Exception as exc: # pragma: no cover - network path
232
+ gateway_error = exc
233
+
234
+ alchemy_key = str(settings.alchemy_api_key or "").strip()
235
+ if not alchemy_key:
236
+ if gateway_error is not None:
237
+ raise ProviderError("evm-portfolio", f"No fallback Alchemy key available after gateway failure: {gateway_error}")
238
+ raise ProviderError("evm-portfolio", "No gateway URL or Alchemy API key configured for EVM portfolio lookup.")
239
+
240
+ base_url = ALCHEMY_RPC_URLS.get(network)
241
+ if not base_url:
242
+ raise ProviderError("evm-portfolio", f"No Alchemy RPC URL configured for {network}.")
243
+ try:
244
+ response = await client.post(f"{base_url}/{alchemy_key}", json=payload)
245
+ except Exception as exc: # pragma: no cover - network path
246
+ raise ProviderError("evm-portfolio", f"Alchemy request failed: {exc}") from exc
247
+ if response.status_code != 200:
248
+ raise ProviderError("evm-portfolio", f"Alchemy returned HTTP {response.status_code} for {method}.")
249
+ data = response.json()
250
+ if "error" in data:
251
+ raise ProviderError("evm-portfolio", f"Alchemy RPC error: {data['error']}")
252
+ return data
253
+
254
+
255
+ async def fetch_token_balances(address: str, network: str) -> list[dict[str, Any]]:
256
+ normalized_network = _normalize_network(network)
257
+ cache_key = f"{normalized_network}:{address.lower()}"
258
+ cached = _cache_get_token_balances(cache_key)
259
+ if cached is not None:
260
+ return cached
261
+
262
+ data = await _gateway_or_alchemy_rpc_call(
263
+ normalized_network,
264
+ "alchemy_getTokenBalances",
265
+ [address, "erc20"],
266
+ )
267
+ balances = []
268
+ metadata_by_address = TOKEN_METADATA.get(normalized_network, {})
269
+ token_balances = list((data.get("result") or {}).get("tokenBalances") or [])
270
+ for item in token_balances:
271
+ contract = str(item.get("contractAddress") or "").strip()
272
+ raw_hex = str(item.get("tokenBalance") or "").strip().lower()
273
+ if not contract or raw_hex in {"", "0x", "0x0"}:
274
+ continue
275
+ try:
276
+ balance_raw = int(raw_hex, 16)
277
+ except ValueError:
278
+ continue
279
+ if balance_raw <= 0:
280
+ continue
281
+ known = metadata_by_address.get(contract.lower()) or {}
282
+ decimals = known.get("decimals")
283
+ balance_ui = None
284
+ if isinstance(decimals, int) and decimals >= 0:
285
+ balance_ui = Decimal(balance_raw) / (Decimal(10) ** decimals)
286
+ balances.append(
287
+ {
288
+ "contract_address": contract,
289
+ "symbol": known.get("symbol"),
290
+ "name": known.get("name"),
291
+ "decimals": decimals,
292
+ "balance_raw": str(balance_raw),
293
+ "balance_ui": _format_decimal(balance_ui) if balance_ui is not None else None,
294
+ "verified": bool(known),
295
+ "source": "alchemy_getTokenBalances",
296
+ }
297
+ )
298
+
299
+ balances.sort(
300
+ key=lambda item: (
301
+ item.get("balance_ui") is None,
302
+ -(Decimal(item["balance_ui"]) if item.get("balance_ui") is not None else Decimal(0)),
303
+ )
304
+ )
305
+ _cache_set_token_balances(cache_key, balances)
306
+ return balances
307
+
308
+
309
+ async def fetch_usd_prices(symbols: list[str]) -> dict[str, float]:
310
+ normalized_symbols = []
311
+ seen: set[str] = set()
312
+ prices: dict[str, float] = {}
313
+ for symbol in symbols:
314
+ normalized = str(symbol or "").strip().upper()
315
+ if not normalized or normalized in seen:
316
+ continue
317
+ seen.add(normalized)
318
+ cached = _cache_get_price(normalized)
319
+ if cached is not None:
320
+ prices[normalized] = cached
321
+ continue
322
+ if normalized in COINGECKO_IDS:
323
+ normalized_symbols.append(normalized)
324
+
325
+ if not normalized_symbols:
326
+ return prices
327
+
328
+ ids = [COINGECKO_IDS[symbol] for symbol in normalized_symbols]
329
+ client = get_client()
330
+ try:
331
+ response = await client.get(
332
+ f"{COINGECKO_API_URL}/simple/price",
333
+ params={
334
+ "ids": ",".join(ids),
335
+ "vs_currencies": "usd",
336
+ },
337
+ )
338
+ except Exception as exc: # pragma: no cover - network path
339
+ raise ProviderError("evm-portfolio", f"CoinGecko request failed: {exc}") from exc
340
+
341
+ if response.status_code != 200:
342
+ raise ProviderError("evm-portfolio", f"CoinGecko returned HTTP {response.status_code}.")
343
+
344
+ payload = response.json()
345
+ for symbol in normalized_symbols:
346
+ cg_id = COINGECKO_IDS[symbol]
347
+ usd_value = payload.get(cg_id, {}).get("usd")
348
+ if usd_value is None:
349
+ continue
350
+ price = float(usd_value)
351
+ prices[symbol] = price
352
+ _cache_set_price(symbol, price)
353
+ return prices
354
+
355
+
356
+ async def build_portfolio_snapshot(
357
+ *,
358
+ address: str,
359
+ network: str,
360
+ native_symbol: str,
361
+ native_balance_wei: str,
362
+ native_balance: str,
363
+ ) -> dict[str, Any]:
364
+ normalized_network = _normalize_network(network)
365
+ tokens = await fetch_token_balances(address, normalized_network)
366
+ symbols = [native_symbol] + [
367
+ str(token.get("symbol") or "").strip()
368
+ for token in tokens
369
+ if str(token.get("symbol") or "").strip()
370
+ ]
371
+ prices = await fetch_usd_prices(symbols)
372
+
373
+ native_balance_decimal = _to_decimal(native_balance) or Decimal(0)
374
+ native_price_usd = prices.get(str(native_symbol or "").upper())
375
+ native_value_usd = (
376
+ native_balance_decimal * Decimal(str(native_price_usd))
377
+ if native_price_usd is not None and native_balance_decimal > 0
378
+ else None
379
+ )
380
+
381
+ portfolio_tokens: list[dict[str, Any]] = []
382
+ total_value = native_value_usd or Decimal(0)
383
+
384
+ for token in tokens:
385
+ symbol = str(token.get("symbol") or "").strip().upper()
386
+ balance_ui_decimal = _to_decimal(token.get("balance_ui"))
387
+ price_usd = prices.get(symbol) if symbol else None
388
+ value_usd = (
389
+ balance_ui_decimal * Decimal(str(price_usd))
390
+ if price_usd is not None and balance_ui_decimal is not None and balance_ui_decimal > 0
391
+ else None
392
+ )
393
+ if value_usd is not None:
394
+ total_value += value_usd
395
+ portfolio_tokens.append(
396
+ {
397
+ "token_address": token["contract_address"],
398
+ "balance_raw": token["balance_raw"],
399
+ "balance_ui": token.get("balance_ui"),
400
+ "token_metadata": {
401
+ "address": token["contract_address"],
402
+ "name": token.get("name"),
403
+ "symbol": token.get("symbol"),
404
+ "decimals": token.get("decimals"),
405
+ "verified": bool(token.get("verified")),
406
+ "source": token.get("source") or "alchemy_getTokenBalances",
407
+ },
408
+ "price_usd": _format_decimal(Decimal(str(price_usd)), places=6) if price_usd is not None else None,
409
+ "value_usd": _format_decimal(value_usd, places=2),
410
+ }
411
+ )
412
+
413
+ portfolio_tokens.sort(
414
+ key=lambda item: _to_decimal(item.get("value_usd")) or Decimal("-1"),
415
+ reverse=True,
416
+ )
417
+
418
+ native_asset = {
419
+ "asset_type": "native",
420
+ "symbol": native_symbol,
421
+ "amount_raw": str(native_balance_wei),
422
+ "amount_ui": str(native_balance),
423
+ "price_usd": _format_decimal(Decimal(str(native_price_usd)), places=6)
424
+ if native_price_usd is not None
425
+ else None,
426
+ "value_usd": _format_decimal(native_value_usd, places=2),
427
+ "pricing_source": "coingecko" if native_price_usd is not None else None,
428
+ }
429
+ assets = [native_asset]
430
+ assets.extend(
431
+ {
432
+ "asset_type": "erc20",
433
+ "token_address": token.get("token_address"),
434
+ "symbol": (token.get("token_metadata") or {}).get("symbol"),
435
+ "amount_raw": token.get("balance_raw"),
436
+ "amount_ui": token.get("balance_ui"),
437
+ "decimals": (token.get("token_metadata") or {}).get("decimals"),
438
+ "price_usd": token.get("price_usd"),
439
+ "value_usd": token.get("value_usd"),
440
+ "pricing_source": "coingecko" if token.get("price_usd") is not None else None,
441
+ }
442
+ for token in portfolio_tokens
443
+ )
444
+ assets.sort(
445
+ key=lambda item: _to_decimal(item.get("value_usd")) or Decimal("-1"),
446
+ reverse=True,
447
+ )
448
+ priced_asset_count = sum(1 for asset in assets if asset.get("value_usd") is not None)
449
+ formatted_total_value = _format_decimal(total_value, places=2) if total_value > 0 else None
450
+
451
+ return {
452
+ "address": address,
453
+ "network": normalized_network,
454
+ "asset": native_symbol,
455
+ "balance_wei": str(native_balance_wei),
456
+ "balance_native": str(native_balance),
457
+ "native_price_usd": _format_decimal(Decimal(str(native_price_usd)), places=6)
458
+ if native_price_usd is not None
459
+ else None,
460
+ "native_value_usd": _format_decimal(native_value_usd, places=2),
461
+ "tokens": portfolio_tokens,
462
+ "token_count": len(portfolio_tokens),
463
+ "assets": assets,
464
+ "asset_count": len(assets),
465
+ "priced_asset_count": priced_asset_count,
466
+ "balance_usd": formatted_total_value,
467
+ "total_value_usd": formatted_total_value,
468
+ "pricing_source": "coingecko",
469
+ "token_discovery_source": "alchemy_getTokenBalances",
470
+ }