File size: 37,091 Bytes
b5b9c2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
#!/usr/bin/env python3
"""
Base Blockchain CLI Tool for Hermes Agent
------------------------------------------
Queries the Base (Ethereum L2) JSON-RPC API and CoinGecko for enriched on-chain data.
Uses only Python standard library — no external packages required.

Usage:
  python3 base_client.py stats
  python3 base_client.py wallet   <address> [--limit N] [--all] [--no-prices]
  python3 base_client.py tx       <hash>
  python3 base_client.py token    <contract_address>
  python3 base_client.py gas
  python3 base_client.py contract <address>
  python3 base_client.py whales   [--min-eth N]
  python3 base_client.py price    <contract_address_or_symbol>

Environment:
  BASE_RPC_URL  Override the default RPC endpoint (default: https://mainnet.base.org)
"""

import argparse
import json
import os
import sys
import time
import urllib.request
import urllib.error
from typing import Any, Dict, List, Optional, Tuple

RPC_URL = os.environ.get(
    "BASE_RPC_URL",
    "https://mainnet.base.org",
)

WEI_PER_ETH = 10**18
GWEI = 10**9

# ERC-20 function selectors (first 4 bytes of keccak256 hash)
SEL_BALANCE_OF   = "70a08231"
SEL_NAME         = "06fdde03"
SEL_SYMBOL       = "95d89b41"
SEL_DECIMALS     = "313ce567"
SEL_TOTAL_SUPPLY = "18160ddd"

# ERC-165 supportsInterface(bytes4) selector
SEL_SUPPORTS_INTERFACE = "01ffc9a7"

# Interface IDs for ERC-165 detection
IFACE_ERC721  = "80ac58cd"
IFACE_ERC1155 = "d9b67a26"

# Transfer(address,address,uint256) event topic
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"

# Well-known Base tokens — maps lowercase address -> (symbol, name, decimals).
KNOWN_TOKENS: Dict[str, Tuple[str, str, int]] = {
    "0x4200000000000000000000000000000000000006": ("WETH",   "Wrapped Ether",               18),
    "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": ("USDC",   "USD Coin",                     6),
    "0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22": ("cbETH",  "Coinbase Wrapped Staked ETH", 18),
    "0x940181a94a35a4569e4529a3cdfb74e38fd98631": ("AERO",   "Aerodrome Finance",           18),
    "0x4ed4e862860bed51a9570b96d89af5e1b0efefed": ("DEGEN",  "Degen",                       18),
    "0xac1bd2486aaf3b5c0fc3fd868558b082a531b2b4": ("TOSHI",  "Toshi",                       18),
    "0x532f27101965dd16442e59d40670faf5ebb142e4": ("BRETT",  "Brett",                       18),
    "0xa88594d404727625a9437c3f886c7643872296ae": ("WELL",   "Moonwell",                    18),
    "0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452": ("wstETH", "Wrapped Lido Staked ETH",     18),
    "0xb6fe221fe9eef5aba221c348ba20a1bf5e73624c": ("rETH",   "Rocket Pool ETH",             18),
    "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf": ("cbBTC",  "Coinbase Wrapped BTC",         8),
}

# Reverse lookup: symbol -> contract address (for the `price` command).
_SYMBOL_TO_ADDRESS = {v[0].upper(): k for k, v in KNOWN_TOKENS.items()}
_SYMBOL_TO_ADDRESS["ETH"] = "ETH"


# ---------------------------------------------------------------------------
# HTTP / RPC helpers
# ---------------------------------------------------------------------------

def _http_get_json(url: str, timeout: int = 10, retries: int = 2) -> Any:
    """GET JSON from a URL with retry on 429 rate-limit. Returns parsed JSON or None."""
    for attempt in range(retries + 1):
        req = urllib.request.Request(
            url, headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"},
        )
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return json.load(resp)
        except urllib.error.HTTPError as exc:
            if exc.code == 429 and attempt < retries:
                time.sleep(2.0 * (attempt + 1))
                continue
            return None
        except Exception:
            return None
    return None


def _rpc_call(method: str, params: list = None, retries: int = 2) -> Any:
    """Send a JSON-RPC request with retry on 429 rate-limit."""
    payload = json.dumps({
        "jsonrpc": "2.0", "id": 1,
        "method": method, "params": params or [],
    }).encode()

    _headers = {"Content-Type": "application/json", "User-Agent": "HermesAgent/1.0"}

    for attempt in range(retries + 1):
        req = urllib.request.Request(
            RPC_URL, data=payload, headers=_headers, method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=20) as resp:
                body = json.load(resp)
            if "error" in body:
                err = body["error"]
                if isinstance(err, dict) and err.get("code") == 429:
                    if attempt < retries:
                        time.sleep(1.5 * (attempt + 1))
                        continue
                sys.exit(f"RPC error: {err}")
            return body.get("result")
        except urllib.error.HTTPError as exc:
            if exc.code == 429 and attempt < retries:
                time.sleep(1.5 * (attempt + 1))
                continue
            sys.exit(f"RPC HTTP error: {exc}")
        except urllib.error.URLError as exc:
            sys.exit(f"RPC connection error: {exc}")
    return None


# Keep backward compat alias.
rpc = _rpc_call


_BATCH_LIMIT = 10  # Base public RPC limits to 10 calls per batch


def _rpc_batch_chunk(items: list) -> list:
    """Send a single batch of JSON-RPC requests (max _BATCH_LIMIT)."""
    payload = json.dumps(items).encode()
    _headers = {"Content-Type": "application/json", "User-Agent": "HermesAgent/1.0"}

    for attempt in range(3):
        req = urllib.request.Request(
            RPC_URL, data=payload, headers=_headers, method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                data = json.load(resp)
            # If the RPC returns an error dict instead of a list, treat as failure
            if isinstance(data, dict) and "error" in data:
                sys.exit(f"RPC batch error: {data['error']}")
            return data if isinstance(data, list) else []
        except urllib.error.HTTPError as exc:
            if exc.code == 429 and attempt < 2:
                time.sleep(1.5 * (attempt + 1))
                continue
            sys.exit(f"RPC batch HTTP error: {exc}")
        except urllib.error.URLError as exc:
            sys.exit(f"RPC batch error: {exc}")
    return []


def rpc_batch(calls: list) -> list:
    """Send a batch of JSON-RPC requests, auto-chunking to respect limits."""
    items = [
        {"jsonrpc": "2.0", "id": i, "method": c["method"], "params": c.get("params", [])}
        for i, c in enumerate(calls)
    ]

    if len(items) <= _BATCH_LIMIT:
        return _rpc_batch_chunk(items)

    # Split into chunks of _BATCH_LIMIT
    all_results = []
    for start in range(0, len(items), _BATCH_LIMIT):
        chunk = items[start:start + _BATCH_LIMIT]
        all_results.extend(_rpc_batch_chunk(chunk))
    return all_results


def wei_to_eth(wei: int) -> float:
    return wei / WEI_PER_ETH


def wei_to_gwei(wei: int) -> float:
    return wei / GWEI


def hex_to_int(hex_str: Optional[str]) -> int:
    """Convert hex string (0x...) to int. Returns 0 for None/empty."""
    if not hex_str or hex_str == "0x":
        return 0
    return int(hex_str, 16)


def print_json(obj: Any) -> None:
    print(json.dumps(obj, indent=2))


def _short_addr(addr: str) -> str:
    """Abbreviate an address for display: first 6 + last 4."""
    if len(addr) <= 14:
        return addr
    return f"{addr[:6]}...{addr[-4:]}"


# ---------------------------------------------------------------------------
# ABI encoding / decoding helpers
# ---------------------------------------------------------------------------

def _encode_address(addr: str) -> str:
    """ABI-encode an address as a 32-byte hex string (no 0x prefix)."""
    clean = addr.lower().replace("0x", "")
    return clean.zfill(64)


def _decode_uint(hex_data: Optional[str]) -> int:
    """Decode a hex-encoded uint256 return value."""
    if not hex_data or hex_data == "0x":
        return 0
    return int(hex_data.replace("0x", ""), 16)


def _decode_string(hex_data: Optional[str]) -> str:
    """Decode an ABI-encoded string return value."""
    if not hex_data or hex_data == "0x" or len(hex_data) < 130:
        return ""
    data = hex_data[2:] if hex_data.startswith("0x") else hex_data
    try:
        length = int(data[64:128], 16)
        if length == 0 or length > 256:
            return ""
        str_hex = data[128:128 + length * 2]
        return bytes.fromhex(str_hex).decode("utf-8").strip("\x00")
    except (ValueError, UnicodeDecodeError):
        return ""


def _eth_call(to: str, selector: str, args: str = "", block: str = "latest") -> Optional[str]:
    """Execute eth_call with a function selector. Returns None on revert/error."""
    data = "0x" + selector + args
    try:
        payload = json.dumps({
            "jsonrpc": "2.0", "id": 1,
            "method": "eth_call", "params": [{"to": to, "data": data}, block],
        }).encode()
        req = urllib.request.Request(
            RPC_URL, data=payload,
            headers={"Content-Type": "application/json", "User-Agent": "HermesAgent/1.0"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=20) as resp:
            body = json.load(resp)
        if "error" in body:
            return None
        return body.get("result")
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Price & token name helpers (CoinGecko — free, no API key)
# ---------------------------------------------------------------------------

def fetch_prices(addresses: List[str], max_lookups: int = 20) -> Dict[str, float]:
    """Fetch USD prices for Base token addresses via CoinGecko (one per request).

    CoinGecko free tier doesn't support batch Base token lookups,
    so we do individual calls — capped at *max_lookups* to stay within
    rate limits. Returns {lowercase_address: usd_price}.
    """
    prices: Dict[str, float] = {}
    for i, addr in enumerate(addresses[:max_lookups]):
        url = (
            f"https://api.coingecko.com/api/v3/simple/token_price/base"
            f"?contract_addresses={addr}&vs_currencies=usd"
        )
        data = _http_get_json(url, timeout=10)
        if data and isinstance(data, dict):
            for key, info in data.items():
                if isinstance(info, dict) and "usd" in info:
                    prices[addr.lower()] = info["usd"]
                    break
        # Pause between calls to respect CoinGecko free-tier rate-limits
        if i < len(addresses[:max_lookups]) - 1:
            time.sleep(1.0)
    return prices


def fetch_eth_price() -> Optional[float]:
    """Fetch current ETH price in USD via CoinGecko."""
    data = _http_get_json(
        "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd"
    )
    if data and "ethereum" in data:
        return data["ethereum"].get("usd")
    return None


def resolve_token_name(addr: str) -> Optional[Dict[str, str]]:
    """Look up token name and symbol. Checks known tokens first, then on-chain.

    Returns {"name": ..., "symbol": ...} or None.
    """
    addr_lower = addr.lower()
    if addr_lower in KNOWN_TOKENS:
        sym, name, _ = KNOWN_TOKENS[addr_lower]
        return {"symbol": sym, "name": name}
    # Try reading name() and symbol() from the contract
    name_hex = _eth_call(addr, SEL_NAME)
    symbol_hex = _eth_call(addr, SEL_SYMBOL)
    name = _decode_string(name_hex) if name_hex else ""
    symbol = _decode_string(symbol_hex) if symbol_hex else ""
    if symbol:
        return {"symbol": symbol.upper(), "name": name}
    return None


def _token_label(addr: str) -> str:
    """Return a human-readable label: symbol if known, else abbreviated address."""
    addr_lower = addr.lower()
    if addr_lower in KNOWN_TOKENS:
        return KNOWN_TOKENS[addr_lower][0]
    return _short_addr(addr)


# ---------------------------------------------------------------------------
# 1. Network Stats
# ---------------------------------------------------------------------------

def cmd_stats(_args):
    """Base network health: block, gas, chain ID, ETH price."""
    results = rpc_batch([
        {"method": "eth_blockNumber"},
        {"method": "eth_gasPrice"},
        {"method": "eth_chainId"},
        {"method": "eth_getBlockByNumber", "params": ["latest", False]},
    ])

    by_id = {r["id"]: r.get("result") for r in results}

    block_num = hex_to_int(by_id.get(0))
    gas_price = hex_to_int(by_id.get(1))
    chain_id  = hex_to_int(by_id.get(2))
    block     = by_id.get(3) or {}

    base_fee  = hex_to_int(block.get("baseFeePerGas")) if block.get("baseFeePerGas") else None
    timestamp = hex_to_int(block.get("timestamp")) if block.get("timestamp") else None
    gas_used  = hex_to_int(block.get("gasUsed")) if block.get("gasUsed") else None
    gas_limit = hex_to_int(block.get("gasLimit")) if block.get("gasLimit") else None
    tx_count  = len(block.get("transactions", []))

    eth_price = fetch_eth_price()

    out = {
        "chain":            "Base" if chain_id == 8453 else f"Chain {chain_id}",
        "chain_id":         chain_id,
        "latest_block":     block_num,
        "gas_price_gwei":   round(wei_to_gwei(gas_price), 4),
    }
    if base_fee is not None:
        out["base_fee_gwei"] = round(wei_to_gwei(base_fee), 4)
    if timestamp:
        out["block_timestamp"] = timestamp
    if gas_used is not None and gas_limit:
        out["block_gas_used"]         = gas_used
        out["block_gas_limit"]        = gas_limit
        out["block_utilization_pct"]  = round(gas_used / gas_limit * 100, 2)
    out["block_tx_count"] = tx_count
    if eth_price is not None:
        out["eth_price_usd"] = eth_price
    print_json(out)


# ---------------------------------------------------------------------------
# 2. Wallet Info (ETH + ERC-20 balances with prices)
# ---------------------------------------------------------------------------

def cmd_wallet(args):
    """ETH balance + ERC-20 token holdings with USD values."""
    address  = args.address.lower()
    show_all = getattr(args, "all", False)
    limit    = getattr(args, "limit", 20) or 20
    skip_prices = getattr(args, "no_prices", False)

    # Batch: ETH balance + balanceOf for all known tokens
    calls = [{"method": "eth_getBalance", "params": [address, "latest"]}]
    token_addrs = list(KNOWN_TOKENS.keys())
    for token_addr in token_addrs:
        calls.append({
            "method": "eth_call",
            "params": [
                {"to": token_addr, "data": "0x" + SEL_BALANCE_OF + _encode_address(address)},
                "latest",
            ],
        })

    results = rpc_batch(calls)
    by_id = {r["id"]: r.get("result") for r in results}

    eth_balance = wei_to_eth(hex_to_int(by_id.get(0)))

    # Parse token balances
    tokens = []
    for i, token_addr in enumerate(token_addrs):
        raw = hex_to_int(by_id.get(i + 1))
        if raw == 0:
            continue
        sym, name, decimals = KNOWN_TOKENS[token_addr]
        amount = raw / (10 ** decimals)
        tokens.append({
            "address":  token_addr,
            "symbol":   sym,
            "name":     name,
            "amount":   amount,
            "decimals": decimals,
        })

    # Fetch prices
    eth_price = None
    prices: Dict[str, float] = {}
    if not skip_prices:
        eth_price = fetch_eth_price()
        if tokens:
            mints_to_price = [t["address"] for t in tokens]
            prices = fetch_prices(mints_to_price, max_lookups=20)

    # Enrich with USD values, filter dust, sort
    enriched = []
    dust_count = 0
    dust_value = 0.0
    for t in tokens:
        usd_price = prices.get(t["address"])
        usd_value = round(usd_price * t["amount"], 2) if usd_price else None

        if not show_all and usd_value is not None and usd_value < 0.01:
            dust_count += 1
            dust_value += usd_value
            continue

        entry = {"token": t["symbol"], "address": t["address"], "amount": t["amount"]}
        if usd_price is not None:
            entry["price_usd"] = usd_price
            entry["value_usd"] = usd_value
        enriched.append(entry)

    # Sort: tokens with known USD value first (highest->lowest), then unknowns
    enriched.sort(
        key=lambda x: (x.get("value_usd") is not None, x.get("value_usd") or 0),
        reverse=True,
    )

    # Apply limit unless --all
    total_tokens = len(enriched)
    if not show_all and len(enriched) > limit:
        enriched = enriched[:limit]
    hidden_tokens = total_tokens - len(enriched)

    # Compute portfolio total
    total_usd = sum(t.get("value_usd", 0) for t in enriched)
    eth_value_usd = round(eth_price * eth_balance, 2) if eth_price else None
    if eth_value_usd:
        total_usd += eth_value_usd
    total_usd += dust_value

    output = {
        "address":     args.address,
        "eth_balance": round(eth_balance, 18),
    }
    if eth_price:
        output["eth_price_usd"] = eth_price
        output["eth_value_usd"] = eth_value_usd
    output["tokens_shown"] = len(enriched)
    if hidden_tokens > 0:
        output["tokens_hidden"] = hidden_tokens
    output["erc20_tokens"] = enriched
    if dust_count > 0:
        output["dust_filtered"] = {"count": dust_count, "total_value_usd": round(dust_value, 4)}
    if total_usd > 0:
        output["portfolio_total_usd"] = round(total_usd, 2)
    if hidden_tokens > 0 and not show_all:
        output["warning"] = (
            "portfolio_total_usd may be partial because hidden tokens are not "
            "included when --limit is applied."
        )
    output["note"] = f"Checked {len(KNOWN_TOKENS)} known Base tokens. Unknown ERC-20s not shown."

    print_json(output)


# ---------------------------------------------------------------------------
# 3. Transaction Details
# ---------------------------------------------------------------------------

def cmd_tx(args):
    """Full transaction details by hash."""
    tx_hash = args.hash

    results = rpc_batch([
        {"method": "eth_getTransactionByHash", "params": [tx_hash]},
        {"method": "eth_getTransactionReceipt", "params": [tx_hash]},
    ])

    by_id = {r["id"]: r.get("result") for r in results}
    tx      = by_id.get(0)
    receipt = by_id.get(1)

    if tx is None:
        sys.exit("Transaction not found.")

    value_wei = hex_to_int(tx.get("value"))
    tx_gas_price = hex_to_int(tx.get("gasPrice"))
    gas_used = hex_to_int(receipt.get("gasUsed")) if receipt else None
    effective_gas_price = (
        hex_to_int(receipt.get("effectiveGasPrice")) if receipt and receipt.get("effectiveGasPrice")
        else tx_gas_price
    )
    l2_fee_wei = effective_gas_price * gas_used if gas_used is not None else None
    l1_fee_wei = hex_to_int(receipt.get("l1Fee")) if receipt and receipt.get("l1Fee") else 0
    fee_wei = (l2_fee_wei + l1_fee_wei) if l2_fee_wei is not None else None

    eth_price = fetch_eth_price()

    out = {
        "hash":           tx_hash,
        "block":          hex_to_int(tx.get("blockNumber")),
        "from":           tx.get("from"),
        "to":             tx.get("to"),
        "value_ETH":      round(wei_to_eth(value_wei), 18) if value_wei else 0,
        "gas_price_gwei": round(wei_to_gwei(effective_gas_price), 4),
    }
    if gas_used is not None:
        out["gas_used"] = gas_used
    if l2_fee_wei is not None:
        out["l2_fee_ETH"] = round(wei_to_eth(l2_fee_wei), 12)
    if l1_fee_wei:
        out["l1_fee_ETH"] = round(wei_to_eth(l1_fee_wei), 12)
    if fee_wei is not None:
        out["fee_ETH"] = round(wei_to_eth(fee_wei), 12)
    if receipt:
        out["status"] = "success" if receipt.get("status") == "0x1" else "failed"
        out["contract_created"] = receipt.get("contractAddress")
        out["log_count"] = len(receipt.get("logs", []))

    # Decode ERC-20 transfers from logs
    transfers = []
    if receipt:
        for log in receipt.get("logs", []):
            topics = log.get("topics", [])
            if len(topics) >= 3 and topics[0] == TRANSFER_TOPIC:
                from_addr = "0x" + topics[1][-40:]
                to_addr   = "0x" + topics[2][-40:]
                token_contract = log.get("address", "")
                label = _token_label(token_contract)

                entry = {
                    "token":    label,
                    "contract": token_contract,
                    "from":     from_addr,
                    "to":       to_addr,
                }
                # ERC-20: 3 topics, amount in data
                if len(topics) == 3:
                    amount_hex = log.get("data", "0x")
                    if amount_hex and amount_hex != "0x":
                        raw_amount = hex_to_int(amount_hex)
                        addr_lower = token_contract.lower()
                        if addr_lower in KNOWN_TOKENS:
                            decimals = KNOWN_TOKENS[addr_lower][2]
                            entry["amount"] = raw_amount / (10 ** decimals)
                        else:
                            entry["raw_amount"] = raw_amount
                # ERC-721: 4 topics, tokenId in topics[3]
                elif len(topics) == 4:
                    entry["token_id"] = hex_to_int(topics[3])
                    entry["type"] = "ERC-721"

                transfers.append(entry)

    if transfers:
        out["token_transfers"] = transfers

    if eth_price is not None:
        if value_wei:
            out["value_USD"] = round(wei_to_eth(value_wei) * eth_price, 2)
        if l2_fee_wei is not None:
            out["l2_fee_USD"] = round(wei_to_eth(l2_fee_wei) * eth_price, 4)
        if l1_fee_wei:
            out["l1_fee_USD"] = round(wei_to_eth(l1_fee_wei) * eth_price, 4)
        if fee_wei is not None:
            out["fee_USD"] = round(wei_to_eth(fee_wei) * eth_price, 4)

    print_json(out)


# ---------------------------------------------------------------------------
# 4. Token Info
# ---------------------------------------------------------------------------

def cmd_token(args):
    """ERC-20 token metadata, supply, price, market cap."""
    addr = args.address.lower()

    # Batch: name, symbol, decimals, totalSupply, code check
    calls = [
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_NAME}, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_SYMBOL}, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_DECIMALS}, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_TOTAL_SUPPLY}, "latest"]},
        {"method": "eth_getCode", "params": [addr, "latest"]},
    ]
    results = rpc_batch(calls)
    by_id = {r["id"]: r.get("result") for r in results}

    code = by_id.get(4)
    if not code or code == "0x":
        sys.exit("Address is not a contract.")

    name     = _decode_string(by_id.get(0))
    symbol   = _decode_string(by_id.get(1))
    decimals_raw = by_id.get(2)
    decimals = _decode_uint(decimals_raw)
    total_supply_raw = _decode_uint(by_id.get(3))

    # Fall back to known tokens if on-chain read failed
    if not symbol and addr in KNOWN_TOKENS:
        symbol   = KNOWN_TOKENS[addr][0]
        name     = KNOWN_TOKENS[addr][1]
        decimals = KNOWN_TOKENS[addr][2]

    is_known_token = addr in KNOWN_TOKENS
    is_erc20 = bool((symbol or is_known_token) and decimals_raw and decimals_raw != "0x")
    if not is_erc20:
        sys.exit("Contract does not appear to be an ERC-20 token.")

    total_supply = total_supply_raw / (10 ** decimals) if decimals else total_supply_raw

    # Fetch price
    price_data = fetch_prices([addr])

    out = {"address": args.address}
    if name:
        out["name"] = name
    if symbol:
        out["symbol"] = symbol
    out["decimals"]    = decimals
    out["total_supply"] = round(total_supply, min(decimals, 6))
    out["code_size_bytes"] = (len(code) - 2) // 2
    if addr in price_data:
        out["price_usd"]      = price_data[addr]
        out["market_cap_usd"] = round(price_data[addr] * total_supply, 0)

    print_json(out)


# ---------------------------------------------------------------------------
# 5. Gas Analysis (Base-specific: L2 execution + L1 data costs)
# ---------------------------------------------------------------------------

def cmd_gas(_args):
    """Detailed gas analysis with L1 data fee context and cost estimates."""
    latest_hex = _rpc_call("eth_blockNumber")
    latest = hex_to_int(latest_hex)

    # Get last 10 blocks for trend analysis + current gas price
    block_calls = []
    for i in range(10):
        block_calls.append({
            "method": "eth_getBlockByNumber",
            "params": [hex(latest - i), False],
        })
    block_calls.append({"method": "eth_gasPrice"})

    results = rpc_batch(block_calls)
    by_id = {r["id"]: r.get("result") for r in results}

    current_gas_price = hex_to_int(by_id.get(10))

    base_fees = []
    gas_utilizations = []
    tx_counts = []
    latest_block_info = None

    for i in range(10):
        b = by_id.get(i)
        if not b:
            continue
        bf  = hex_to_int(b.get("baseFeePerGas", "0x0"))
        gu  = hex_to_int(b.get("gasUsed", "0x0"))
        gl  = hex_to_int(b.get("gasLimit", "0x0"))
        txc = len(b.get("transactions", []))
        base_fees.append(bf)
        if gl > 0:
            gas_utilizations.append(gu / gl * 100)
        tx_counts.append(txc)

        if i == 0:
            latest_block_info = {
                "block":            hex_to_int(b.get("number")),
                "base_fee_gwei":    round(wei_to_gwei(bf), 6),
                "gas_used":         gu,
                "gas_limit":        gl,
                "utilization_pct":  round(gu / gl * 100, 2) if gl > 0 else 0,
                "tx_count":         txc,
            }

    avg_base_fee    = sum(base_fees) / len(base_fees) if base_fees else 0
    avg_utilization = sum(gas_utilizations) / len(gas_utilizations) if gas_utilizations else 0
    avg_tx_count    = sum(tx_counts) / len(tx_counts) if tx_counts else 0

    # Estimate costs for common operations
    eth_price = fetch_eth_price()

    simple_transfer_gas = 21_000
    erc20_transfer_gas  = 65_000
    swap_gas            = 200_000

    def _estimate_cost(gas: int) -> Dict[str, Any]:
        cost_wei = gas * current_gas_price
        cost_eth = wei_to_eth(cost_wei)
        entry: Dict[str, Any] = {"gas_units": gas, "cost_ETH": round(cost_eth, 10)}
        if eth_price:
            entry["cost_USD"] = round(cost_eth * eth_price, 6)
        return entry

    out: Dict[str, Any] = {
        "current_gas_price_gwei": round(wei_to_gwei(current_gas_price), 6),
        "latest_block":           latest_block_info,
        "trend_10_blocks": {
            "avg_base_fee_gwei":    round(wei_to_gwei(avg_base_fee), 6),
            "avg_utilization_pct":  round(avg_utilization, 2),
            "avg_tx_count":         round(avg_tx_count, 1),
            "min_base_fee_gwei":    round(wei_to_gwei(min(base_fees)), 6) if base_fees else None,
            "max_base_fee_gwei":    round(wei_to_gwei(max(base_fees)), 6) if base_fees else None,
        },
        "cost_estimates": {
            "eth_transfer":   _estimate_cost(simple_transfer_gas),
            "erc20_transfer": _estimate_cost(erc20_transfer_gas),
            "swap":           _estimate_cost(swap_gas),
        },
        "note": "Base is an L2. Total tx cost = L2 execution fee + L1 data posting fee. "
                "L1 data fee depends on calldata size and L1 gas prices (not shown here). "
                "Actual costs may be slightly higher than estimates.",
    }
    if eth_price:
        out["eth_price_usd"] = eth_price
    print_json(out)


# ---------------------------------------------------------------------------
# 6. Contract Inspection
# ---------------------------------------------------------------------------

def cmd_contract(args):
    """Inspect an address: EOA vs contract, ERC type detection, proxy resolution."""
    addr = args.address.lower()

    # Batch: getCode, getBalance, name, symbol, decimals, totalSupply, ERC-721, ERC-1155
    calls = [
        {"method": "eth_getCode",    "params": [addr, "latest"]},
        {"method": "eth_getBalance", "params": [addr, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_NAME}, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_SYMBOL}, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_DECIMALS}, "latest"]},
        {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_TOTAL_SUPPLY}, "latest"]},
        {"method": "eth_call", "params": [
            {"to": addr, "data": "0x" + SEL_SUPPORTS_INTERFACE + IFACE_ERC721.zfill(64)},
            "latest",
        ]},
        {"method": "eth_call", "params": [
            {"to": addr, "data": "0x" + SEL_SUPPORTS_INTERFACE + IFACE_ERC1155.zfill(64)},
            "latest",
        ]},
    ]
    results = rpc_batch(calls)

    # Handle per-item errors gracefully
    by_id: Dict[int, Any] = {}
    for r in results:
        if "error" not in r:
            by_id[r["id"]] = r.get("result")
        else:
            by_id[r["id"]] = None

    code        = by_id.get(0, "0x")
    eth_balance = hex_to_int(by_id.get(1))

    if not code or code == "0x":
        out = {
            "address":     args.address,
            "is_contract": False,
            "eth_balance": round(wei_to_eth(eth_balance), 18),
            "note":        "This is an externally owned account (EOA), not a contract.",
        }
        print_json(out)
        return

    code_size = (len(code) - 2) // 2

    # Check ERC-20
    name         = _decode_string(by_id.get(2))
    symbol       = _decode_string(by_id.get(3))
    decimals_raw = by_id.get(4)
    supply_raw   = by_id.get(5)
    is_erc20     = bool(symbol and decimals_raw and decimals_raw != "0x")

    # Check ERC-721 / ERC-1155 via ERC-165
    erc721_result  = by_id.get(6)
    erc1155_result = by_id.get(7)
    is_erc721  = erc721_result is not None and _decode_uint(erc721_result) == 1
    is_erc1155 = erc1155_result is not None and _decode_uint(erc1155_result) == 1

    # Detect proxy pattern (EIP-1967 implementation slot)
    impl_slot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
    impl_result = _rpc_call("eth_getStorageAt", [addr, impl_slot, "latest"])
    is_proxy = False
    impl_address = None
    if impl_result and impl_result != "0x" + "0" * 64:
        impl_address = "0x" + impl_result[-40:]
        if impl_address != "0x" + "0" * 40:
            is_proxy = True

    out: Dict[str, Any] = {
        "address":        args.address,
        "is_contract":    True,
        "code_size_bytes": code_size,
        "eth_balance":    round(wei_to_eth(eth_balance), 18),
    }

    interfaces = []
    if is_erc20:
        interfaces.append("ERC-20")
    if is_erc721:
        interfaces.append("ERC-721")
    if is_erc1155:
        interfaces.append("ERC-1155")
    if interfaces:
        out["detected_interfaces"] = interfaces

    if is_erc20:
        decimals = _decode_uint(decimals_raw)
        supply   = _decode_uint(supply_raw)
        out["erc20"] = {
            "name":         name,
            "symbol":       symbol,
            "decimals":     decimals,
            "total_supply": supply / (10 ** decimals) if decimals else supply,
        }

    if is_proxy:
        out["proxy"] = {
            "is_proxy":       True,
            "implementation": impl_address,
            "standard":       "EIP-1967",
        }

    # Check known tokens
    if addr in KNOWN_TOKENS:
        sym, tname, _ = KNOWN_TOKENS[addr]
        out["known_token"] = {"symbol": sym, "name": tname}

    print_json(out)


# ---------------------------------------------------------------------------
# 7. Whale Detector
# ---------------------------------------------------------------------------

def cmd_whales(args):
    """Scan the latest block for large ETH transfers with USD values."""
    min_wei = int(args.min_eth * WEI_PER_ETH)

    block = rpc("eth_getBlockByNumber", ["latest", True])
    if block is None:
        sys.exit("Could not retrieve latest block.")

    eth_price = fetch_eth_price()

    whales = []
    for tx in (block.get("transactions") or []):
        value = hex_to_int(tx.get("value"))
        if value >= min_wei:
            entry: Dict[str, Any] = {
                "hash": tx.get("hash"),
                "from": tx.get("from"),
                "to":   tx.get("to"),
                "value_ETH": round(wei_to_eth(value), 6),
            }
            if eth_price:
                entry["value_USD"] = round(wei_to_eth(value) * eth_price, 2)
            whales.append(entry)

    # Sort by value descending
    whales.sort(key=lambda x: x["value_ETH"], reverse=True)

    out: Dict[str, Any] = {
        "block":              hex_to_int(block.get("number")),
        "block_time":         hex_to_int(block.get("timestamp")),
        "min_threshold_ETH":  args.min_eth,
        "large_transfers":    whales,
        "note":               "Scans latest block only — point-in-time snapshot.",
    }
    if eth_price:
        out["eth_price_usd"] = eth_price
    print_json(out)


# ---------------------------------------------------------------------------
# 8. Price Lookup
# ---------------------------------------------------------------------------

def cmd_price(args):
    """Quick price lookup for a token by contract address or known symbol."""
    query = args.token

    # Check if it's a known symbol
    addr = _SYMBOL_TO_ADDRESS.get(query.upper(), query).lower()

    # Special case: ETH itself
    if addr == "eth":
        eth_price = fetch_eth_price()
        out: Dict[str, Any] = {"query": query, "token": "ETH", "name": "Ethereum"}
        if eth_price:
            out["price_usd"] = eth_price
        else:
            out["price_usd"] = None
            out["note"] = "Price not available."
        print_json(out)
        return

    # Resolve name
    token_meta = resolve_token_name(addr)

    # Fetch price
    prices = fetch_prices([addr])

    out = {"query": query, "address": addr}
    if token_meta:
        out["name"]   = token_meta["name"]
        out["symbol"] = token_meta["symbol"]
    if addr in prices:
        out["price_usd"] = prices[addr]
    else:
        out["price_usd"] = None
        out["note"] = "Price not available — token may not be listed on CoinGecko."
    print_json(out)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        prog="base_client.py",
        description="Base blockchain query tool for Hermes Agent",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("stats", help="Network stats: block, gas, chain ID, ETH price")

    p_wallet = sub.add_parser("wallet", help="ETH balance + ERC-20 tokens with USD values")
    p_wallet.add_argument("address")
    p_wallet.add_argument("--limit", type=int, default=20,
                          help="Max tokens to display (default: 20)")
    p_wallet.add_argument("--all", action="store_true",
                          help="Show all tokens (no limit, no dust filter)")
    p_wallet.add_argument("--no-prices", action="store_true",
                          help="Skip price lookups (faster, RPC-only)")

    p_tx = sub.add_parser("tx", help="Transaction details by hash")
    p_tx.add_argument("hash")

    p_token = sub.add_parser("token", help="ERC-20 token metadata, price, and market cap")
    p_token.add_argument("address")

    sub.add_parser("gas", help="Gas analysis with cost estimates and L1 data fee context")

    p_contract = sub.add_parser("contract", help="Contract inspection: type detection, proxy check")
    p_contract.add_argument("address")

    p_whales = sub.add_parser("whales", help="Large ETH transfers in the latest block")
    p_whales.add_argument("--min-eth", type=float, default=1.0,
                          help="Minimum ETH transfer size (default: 1.0)")

    p_price = sub.add_parser("price", help="Quick price lookup by address or symbol")
    p_price.add_argument("token", help="Contract address or known symbol (ETH, USDC, AERO, ...)")

    args = parser.parse_args()

    dispatch = {
        "stats":    cmd_stats,
        "wallet":   cmd_wallet,
        "tx":       cmd_tx,
        "token":    cmd_token,
        "gas":      cmd_gas,
        "contract": cmd_contract,
        "whales":   cmd_whales,
        "price":    cmd_price,
    }
    dispatch[args.command](args)


if __name__ == "__main__":
    main()