@agentlayer.tech/wallet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.openclaw/AGENTS.md +98 -0
- package/.openclaw/extensions/agent-wallet/README.md +127 -0
- package/.openclaw/extensions/agent-wallet/index.ts +1520 -0
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +184 -0
- package/.openclaw/extensions/agent-wallet/package.json +11 -0
- package/.openclaw/extensions/agent-wallet/skills/wallet-operator/SKILL.md +20 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +104 -0
- package/README.md +332 -0
- package/RELEASING.md +204 -0
- package/agent-wallet/.env.example +62 -0
- package/agent-wallet/AGENTS.md +129 -0
- package/agent-wallet/README.md +527 -0
- package/agent-wallet/agent_wallet/__init__.py +11 -0
- package/agent-wallet/agent_wallet/approval.py +161 -0
- package/agent-wallet/agent_wallet/bootstrap.py +178 -0
- package/agent-wallet/agent_wallet/btc_user_wallets.py +217 -0
- package/agent-wallet/agent_wallet/config.py +382 -0
- package/agent-wallet/agent_wallet/encrypted_storage.py +161 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +370 -0
- package/agent-wallet/agent_wallet/exceptions.py +9 -0
- package/agent-wallet/agent_wallet/file_ops.py +34 -0
- package/agent-wallet/agent_wallet/http_client.py +25 -0
- package/agent-wallet/agent_wallet/models.py +66 -0
- package/agent-wallet/agent_wallet/nonce_registry.py +59 -0
- package/agent-wallet/agent_wallet/openclaw_adapter.py +5128 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +626 -0
- package/agent-wallet/agent_wallet/openclaw_runtime.py +272 -0
- package/agent-wallet/agent_wallet/plugin_bundle.py +42 -0
- package/agent-wallet/agent_wallet/providers/__init__.py +1 -0
- package/agent-wallet/agent_wallet/providers/bags.py +259 -0
- package/agent-wallet/agent_wallet/providers/evm_portfolio.py +470 -0
- package/agent-wallet/agent_wallet/providers/jupiter.py +567 -0
- package/agent-wallet/agent_wallet/providers/kamino.py +215 -0
- package/agent-wallet/agent_wallet/providers/lifi.py +277 -0
- package/agent-wallet/agent_wallet/providers/solana_rpc.py +470 -0
- package/agent-wallet/agent_wallet/providers/wdk_btc_local.py +114 -0
- package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +205 -0
- package/agent-wallet/agent_wallet/sealed_keys.py +61 -0
- package/agent-wallet/agent_wallet/solana_stake.py +103 -0
- package/agent-wallet/agent_wallet/solana_tx.py +93 -0
- package/agent-wallet/agent_wallet/spending_limits.py +101 -0
- package/agent-wallet/agent_wallet/transaction_policy.py +518 -0
- package/agent-wallet/agent_wallet/user_wallets.py +355 -0
- package/agent-wallet/agent_wallet/validation.py +31 -0
- package/agent-wallet/agent_wallet/wallet_layer/__init__.py +1 -0
- package/agent-wallet/agent_wallet/wallet_layer/base.py +808 -0
- package/agent-wallet/agent_wallet/wallet_layer/base58.py +44 -0
- package/agent-wallet/agent_wallet/wallet_layer/factory.py +102 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +4252 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_btc.py +272 -0
- package/agent-wallet/agent_wallet/wallet_layer/wdk_evm.py +1628 -0
- package/agent-wallet/examples/bootstrap_wallet.py +21 -0
- package/agent-wallet/examples/openclaw_runtime_onboarding.py +28 -0
- package/agent-wallet/examples/openclaw_user_wallet_example.py +31 -0
- package/agent-wallet/examples/openclaw_wallet_adapter_example.py +33 -0
- package/agent-wallet/openclaw.plugin.json +138 -0
- package/agent-wallet/pyproject.toml +31 -0
- package/agent-wallet/scripts/bootstrap_openclaw_btc.py +278 -0
- package/agent-wallet/scripts/build_release_bundle.py +188 -0
- package/agent-wallet/scripts/finalize_openclaw_local_wallet_config.py +121 -0
- package/agent-wallet/scripts/install_agent_wallet.py +505 -0
- package/agent-wallet/scripts/install_openclaw_local_config.py +226 -0
- package/agent-wallet/scripts/install_openclaw_sealed_keys.py +105 -0
- package/agent-wallet/scripts/manage_openclaw_btc_wallet.py +244 -0
- package/agent-wallet/scripts/reveal_btc_seed.sh +130 -0
- package/agent-wallet/scripts/security_utils.py +37 -0
- package/agent-wallet/scripts/setup_btc_wallet.sh +146 -0
- package/agent-wallet/scripts/switch_openclaw_wallet_network.py +106 -0
- package/agent-wallet/skills/wallet-operator/SKILL.md +128 -0
- package/bin/openclaw-agent-wallet.mjs +487 -0
- package/install-from-github.sh +134 -0
- package/package.json +61 -0
- package/setup.sh +40 -0
- package/wdk-btc-wallet/README.md +325 -0
- package/wdk-btc-wallet/bootstrap.sh +22 -0
- package/wdk-btc-wallet/package-lock.json +1839 -0
- package/wdk-btc-wallet/package.json +18 -0
- package/wdk-btc-wallet/run-local.sh +21 -0
- package/wdk-btc-wallet/src/config.js +160 -0
- package/wdk-btc-wallet/src/json.js +35 -0
- package/wdk-btc-wallet/src/local_vault.js +432 -0
- package/wdk-btc-wallet/src/network_state.js +84 -0
- package/wdk-btc-wallet/src/server.js +257 -0
- package/wdk-btc-wallet/src/wdk_btc_wallet.js +332 -0
- package/wdk-evm-wallet/README.md +183 -0
- package/wdk-evm-wallet/bootstrap.sh +8 -0
- package/wdk-evm-wallet/package-lock.json +2340 -0
- package/wdk-evm-wallet/package.json +23 -0
- package/wdk-evm-wallet/run-local.sh +12 -0
- package/wdk-evm-wallet/src/config.js +274 -0
- package/wdk-evm-wallet/src/json.js +35 -0
- package/wdk-evm-wallet/src/local_vault.js +430 -0
- package/wdk-evm-wallet/src/network_state.js +92 -0
- package/wdk-evm-wallet/src/server.js +575 -0
- package/wdk-evm-wallet/src/wdk_evm_wallet.js +4981 -0
|
@@ -0,0 +1,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
|
+
}
|