chainq 0.11.0__py3-none-any.whl

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 (46) hide show
  1. chainq/__init__.py +6 -0
  2. chainq/cache.py +42 -0
  3. chainq/cli.py +50 -0
  4. chainq/commands/__init__.py +0 -0
  5. chainq/commands/aave.py +97 -0
  6. chainq/commands/chain.py +239 -0
  7. chainq/commands/config.py +105 -0
  8. chainq/commands/ethena.py +40 -0
  9. chainq/commands/hl.py +368 -0
  10. chainq/commands/lighter.py +169 -0
  11. chainq/commands/llama.py +80 -0
  12. chainq/commands/market.py +257 -0
  13. chainq/commands/morpho.py +125 -0
  14. chainq/commands/nft.py +178 -0
  15. chainq/commands/pendle.py +84 -0
  16. chainq/commands/portfolio.py +119 -0
  17. chainq/commands/protocols.py +17 -0
  18. chainq/commands/sky.py +34 -0
  19. chainq/commands/stables.py +67 -0
  20. chainq/commands/uniswap.py +361 -0
  21. chainq/config.py +24 -0
  22. chainq/errors.py +2 -0
  23. chainq/fmt.py +50 -0
  24. chainq/http.py +32 -0
  25. chainq/networks.py +307 -0
  26. chainq/output.py +158 -0
  27. chainq/providers/__init__.py +0 -0
  28. chainq/providers/aave.py +50 -0
  29. chainq/providers/coingecko.py +189 -0
  30. chainq/providers/defillama.py +137 -0
  31. chainq/providers/ethena.py +35 -0
  32. chainq/providers/hyperliquid.py +102 -0
  33. chainq/providers/lighter.py +60 -0
  34. chainq/providers/morpho.py +69 -0
  35. chainq/providers/opensea.py +56 -0
  36. chainq/providers/pendle.py +25 -0
  37. chainq/providers/sky.py +28 -0
  38. chainq/providers/uniswap.py +258 -0
  39. chainq/rpc.py +96 -0
  40. chainq/tokens.py +85 -0
  41. chainq/update.py +128 -0
  42. chainq-0.11.0.dist-info/METADATA +241 -0
  43. chainq-0.11.0.dist-info/RECORD +46 -0
  44. chainq-0.11.0.dist-info/WHEEL +4 -0
  45. chainq-0.11.0.dist-info/entry_points.txt +2 -0
  46. chainq-0.11.0.dist-info/licenses/LICENSE +21 -0
chainq/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("chainq")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
chainq/cache.py ADDED
@@ -0,0 +1,42 @@
1
+ import hashlib
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+
6
+ CACHE_DIR = Path.home() / ".cache" / "chainq"
7
+ CACHE_FILE = CACHE_DIR / "http-cache.json"
8
+
9
+
10
+ def key_for(*parts: object) -> str:
11
+ return hashlib.sha256(json.dumps(parts, sort_keys=True, default=str).encode()).hexdigest()[:24]
12
+
13
+
14
+ def _load() -> dict:
15
+ try:
16
+ return json.loads(CACHE_FILE.read_text())
17
+ except Exception:
18
+ return {}
19
+
20
+
21
+ def _store(data: dict) -> None:
22
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
23
+ tmp = CACHE_FILE.with_suffix(".tmp")
24
+ tmp.write_text(json.dumps(data, default=str))
25
+ tmp.replace(CACHE_FILE)
26
+
27
+
28
+ def get(key: str) -> object | None:
29
+ entry = _load().get(key)
30
+ if entry and entry.get("expires_at", 0) > time.time():
31
+ return entry["value"]
32
+ return None
33
+
34
+
35
+ def put(key: str, value: object, ttl: float) -> None:
36
+ now = time.time()
37
+ data = {k: v for k, v in _load().items() if v.get("expires_at", 0) > now}
38
+ data[key] = {"expires_at": now + ttl, "value": value}
39
+ try:
40
+ _store(data)
41
+ except OSError:
42
+ pass
chainq/cli.py ADDED
@@ -0,0 +1,50 @@
1
+ import sys
2
+
3
+ import httpx
4
+ import typer
5
+
6
+ from chainq import __version__, update
7
+ from chainq.commands import chain, config, market, nft, portfolio, protocols, stables
8
+ from chainq.errors import ChainqError
9
+
10
+ app = typer.Typer(
11
+ add_completion=True,
12
+ no_args_is_help=True,
13
+ pretty_exceptions_enable=False,
14
+ context_settings={"help_option_names": ["-h", "--help"]},
15
+ help="Agent-friendly CLI for onchain and crypto market data. Every command supports --json, -q, -v.",
16
+ )
17
+
18
+ app.command()(chain.networks)
19
+ app.command()(chain.balance)
20
+ app.command()(chain.gas)
21
+ app.command()(chain.tx)
22
+ app.command()(chain.rpc)
23
+ app.command()(portfolio.portfolio)
24
+ app.command()(market.price)
25
+ app.command()(market.asset)
26
+ app.command()(market.search)
27
+ app.command()(market.trending)
28
+ app.command()(stables.stables)
29
+ app.command()(update.update)
30
+ app.add_typer(protocols.app, name="protocols")
31
+ app.add_typer(nft.app, name="nft")
32
+ app.add_typer(config.app, name="config")
33
+
34
+
35
+ @app.command()
36
+ def version():
37
+ """Print chainq version."""
38
+ print(__version__)
39
+
40
+
41
+ def run():
42
+ try:
43
+ update.maybe_remind()
44
+ app()
45
+ except ChainqError as exc:
46
+ print(f"error: {exc}", file=sys.stderr)
47
+ sys.exit(1)
48
+ except httpx.HTTPError as exc:
49
+ print(f"error: http request failed: {exc}", file=sys.stderr)
50
+ sys.exit(1)
File without changes
@@ -0,0 +1,97 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from chainq.errors import ChainqError
6
+ from chainq.fmt import fmt_pct, humanize_usd
7
+ from chainq.networks import resolve_network
8
+ from chainq.output import FormatOpt, JsonOpt, Out, QuietOpt, VerboseOpt
9
+ from chainq.providers import aave
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Aave v3 protocol data (lending markets).")
12
+
13
+ SORT_KEYS = {
14
+ "supplied": lambda r: r["supplied_usd"] or 0,
15
+ "supply-apy": lambda r: r["supply_apy_pct"] or 0,
16
+ "borrow-apy": lambda r: r["borrow_apy_pct"] or 0,
17
+ "utilization": lambda r: r["utilization_pct"] or 0,
18
+ }
19
+
20
+
21
+ def _market_label(name: str) -> str:
22
+ return name.removeprefix("AaveV3") or name
23
+
24
+
25
+ def _reserve_row(market: dict, reserve: dict) -> dict:
26
+ supply = reserve.get("supplyInfo") or {}
27
+ borrow = reserve.get("borrowInfo") or {}
28
+ return {
29
+ "market": _market_label(market["name"]),
30
+ "symbol": (reserve.get("underlyingToken") or {}).get("symbol"),
31
+ "supply_apy_pct": float(supply["apy"]["value"]) * 100 if supply.get("apy") else None,
32
+ "borrow_apy_pct": float(borrow["apy"]["value"]) * 100 if borrow.get("apy") else None,
33
+ "supplied_usd": float(reserve["size"]["usd"]) if reserve.get("size") else None,
34
+ "borrowed_usd": float(borrow["total"]["usd"]) if borrow.get("total") else None,
35
+ "available_usd": float(borrow["availableLiquidity"]["usd"]) if borrow.get("availableLiquidity") else None,
36
+ "utilization_pct": float(borrow["utilizationRate"]["value"]) * 100 if borrow.get("utilizationRate") else None,
37
+ "collateral": supply.get("canBeCollateral"),
38
+ "frozen": reserve.get("isFrozen"),
39
+ }
40
+
41
+
42
+ def _row_line(r: dict) -> str:
43
+ borrow = f"borrow {fmt_pct(r['borrow_apy_pct'], signed=False)}" if r["borrow_apy_pct"] is not None else "not borrowable"
44
+ util = f" util {fmt_pct(r['utilization_pct'], signed=False)}" if r["utilization_pct"] is not None else ""
45
+ return (
46
+ f"{r['symbol']} [{r['market']}]: supply {fmt_pct(r['supply_apy_pct'], signed=False)} {borrow} "
47
+ f"supplied {humanize_usd(r['supplied_usd'] or 0)}{util}"
48
+ )
49
+
50
+
51
+ @app.command()
52
+ def markets(
53
+ network: Annotated[str, typer.Option("--network", "-n", help="network key, alias, or chain id")] = "ethereum",
54
+ coin: Annotated[str | None, typer.Option("--coin", "-c", help="filter by underlying token symbol")] = None,
55
+ sort: Annotated[str, typer.Option("--sort", "-s", help="supplied | supply-apy | borrow-apy | utilization")] = "supplied",
56
+ limit: Annotated[int, typer.Option("--limit", "-l")] = 15,
57
+ json_out: JsonOpt = False,
58
+ quiet: QuietOpt = False,
59
+ verbose: VerboseOpt = False,
60
+ format: FormatOpt = "text",
61
+ ):
62
+ """Aave v3 reserves on a network: supply/borrow APY, size, utilization."""
63
+ out = Out(json_out, quiet, verbose, format)
64
+ if sort not in SORT_KEYS:
65
+ raise ChainqError(f"unknown sort '{sort}' (use: {', '.join(SORT_KEYS)})")
66
+ net = resolve_network(network)
67
+ aave_markets = aave.markets(net.chain_id)
68
+ if not aave_markets:
69
+ raise ChainqError(f"no Aave v3 market on {net.name}")
70
+ rows = [
71
+ _reserve_row(market, reserve)
72
+ for market in aave_markets
73
+ for reserve in market.get("reserves") or []
74
+ if not reserve.get("isPaused")
75
+ ]
76
+ if coin:
77
+ rows = [r for r in rows if (r["symbol"] or "").lower() == coin.lower()]
78
+ if not rows:
79
+ raise ChainqError(f"no Aave reserve for '{coin}' on {net.name}")
80
+ else:
81
+ rows = sorted(rows, key=SORT_KEYS[sort], reverse=True)[:limit]
82
+ total = sum(float(m["totalMarketSize"]) for m in aave_markets)
83
+ header = (
84
+ f"Aave v3 on {net.name}: {humanize_usd(total)} total market size, "
85
+ f"{len(aave_markets)} market{'s' if len(aave_markets) != 1 else ''} "
86
+ f"({', '.join(_market_label(m['name']) for m in aave_markets)})"
87
+ )
88
+ out.emit(
89
+ {"network": net.key, "total_market_size_usd": total, "reserves": rows},
90
+ [header, *(_row_line(r) for r in rows)],
91
+ quiet_value="\n".join(f"{r['symbol']}" for r in rows),
92
+ verbose_lines=[
93
+ f"{r['symbol']} [{r['market']}]: borrowed {humanize_usd(r['borrowed_usd'] or 0)}, "
94
+ f"available {humanize_usd(r['available_usd'] or 0)}, collateral {r['collateral']}, frozen {r['frozen']}"
95
+ for r in rows
96
+ ],
97
+ )
@@ -0,0 +1,239 @@
1
+ import json
2
+ from datetime import UTC, datetime
3
+ from decimal import Decimal
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from web3.exceptions import TransactionNotFound
8
+ from web3.types import RPCEndpoint
9
+
10
+ from chainq.errors import ChainqError
11
+ from chainq.fmt import fmt_amount, fmt_gwei, fmt_usd, short_addr
12
+ from chainq.networks import NETWORKS, resolve_network
13
+ from chainq.output import FormatOpt, JsonOpt, Out, QuietOpt, VerboseOpt
14
+ from chainq.providers import coingecko
15
+ from chainq.rpc import connect, erc20, resolve_address
16
+ from chainq.tokens import resolve_token
17
+
18
+ NetworkOpt = Annotated[str, typer.Option("--network", "-n", help="network key, alias, or chain id")]
19
+
20
+
21
+ def networks(
22
+ json_out: JsonOpt = False, quiet: QuietOpt = False, verbose: VerboseOpt = False, format: FormatOpt = "text"
23
+ ):
24
+ """List supported networks with chain ids and aliases."""
25
+ out = Out(json_out, quiet, verbose, format)
26
+ data = [
27
+ {
28
+ "key": net.key,
29
+ "name": net.name,
30
+ "chain_id": net.chain_id,
31
+ "native_symbol": net.native_symbol,
32
+ "aliases": list(net.aliases),
33
+ "rpc_urls": list(net.rpc_urls),
34
+ "explorer": net.explorer,
35
+ }
36
+ for net in NETWORKS.values()
37
+ ]
38
+ lines = [
39
+ f"{net.key} (chain id {net.chain_id}, native {net.native_symbol})"
40
+ + (f" aliases: {', '.join(net.aliases)}" if net.aliases else "")
41
+ for net in NETWORKS.values()
42
+ ]
43
+ verbose_lines = [f"{net.key}: {', '.join(net.rpc_urls)}" for net in NETWORKS.values()]
44
+ out.emit(data, lines, quiet_value="\n".join(NETWORKS), verbose_lines=verbose_lines)
45
+
46
+
47
+ def balance(
48
+ address: Annotated[str, typer.Argument(help="wallet address or ENS name")],
49
+ coin: Annotated[str | None, typer.Option("--coin", "-c", help="token symbol or ERC-20 address; omit for native")] = None,
50
+ network: NetworkOpt = "ethereum",
51
+ json_out: JsonOpt = False,
52
+ quiet: QuietOpt = False,
53
+ verbose: VerboseOpt = False,
54
+ format: FormatOpt = "text",
55
+ ):
56
+ """Get native or ERC-20 token balance of an address."""
57
+ out = Out(json_out, quiet, verbose, format)
58
+ net = resolve_network(network)
59
+ addr = resolve_address(address)
60
+ client = connect(net)
61
+ if coin is None:
62
+ wei = client.w3.eth.get_balance(addr)
63
+ amount = Decimal(wei) / Decimal(10**18)
64
+ symbol = net.native_symbol
65
+ price = coingecko.try_price_usd(net.native_coingecko_id)
66
+ token_address = None
67
+ else:
68
+ token_address = resolve_token(coin, net)
69
+ contract = erc20(client, token_address)
70
+ decimals = contract.functions.decimals().call()
71
+ symbol = contract.functions.symbol().call()
72
+ raw = contract.functions.balanceOf(addr).call()
73
+ amount = Decimal(raw) / Decimal(10**decimals)
74
+ cg_id = coingecko.SYMBOL_TO_ID.get(coin.lower()) if not coin.startswith("0x") else None
75
+ price = coingecko.try_price_usd(cg_id)
76
+ usd_value = float(amount) * price if price is not None else None
77
+ data = {
78
+ "address": addr,
79
+ "input": address,
80
+ "network": net.key,
81
+ "symbol": symbol,
82
+ "token_address": token_address,
83
+ "amount": str(amount),
84
+ "price_usd": price,
85
+ "value_usd": usd_value,
86
+ }
87
+ label = f"{address} ({short_addr(addr)})" if address != addr else short_addr(addr)
88
+ text = f"{label} on {net.name}: {fmt_amount(amount)} {symbol}"
89
+ if usd_value is not None:
90
+ text += f" (~{fmt_usd(usd_value)})"
91
+ out.emit(
92
+ data,
93
+ text,
94
+ quiet_value=amount,
95
+ verbose_lines=[
96
+ f"rpc: {client.url}",
97
+ f"address: {addr}",
98
+ *([f"token: {token_address}"] if token_address else []),
99
+ *([f"price used: {fmt_usd(price)} [coingecko]"] if price is not None else []),
100
+ ],
101
+ )
102
+
103
+
104
+ def gas(
105
+ network: NetworkOpt = "ethereum",
106
+ json_out: JsonOpt = False,
107
+ quiet: QuietOpt = False,
108
+ verbose: VerboseOpt = False,
109
+ format: FormatOpt = "text",
110
+ ):
111
+ """Current gas price, base fee, and estimated native-transfer cost in USD."""
112
+ out = Out(json_out, quiet, verbose, format)
113
+ net = resolve_network(network)
114
+ client = connect(net)
115
+ gas_price = client.w3.eth.gas_price
116
+ base_fee = None
117
+ priority_p50 = None
118
+ try:
119
+ hist = client.w3.eth.fee_history(1, "latest", [25, 50, 90])
120
+ base_fee = hist["baseFeePerGas"][-1]
121
+ rewards = hist.get("reward") or []
122
+ if rewards:
123
+ priority_p50 = rewards[0][1]
124
+ except Exception:
125
+ pass
126
+ price = coingecko.try_price_usd(net.native_coingecko_id)
127
+ transfer_cost_usd = (21000 * gas_price / 1e18) * price if price is not None else None
128
+ data = {
129
+ "network": net.key,
130
+ "gas_price_wei": gas_price,
131
+ "gas_price_gwei": gas_price / 1e9,
132
+ "base_fee_gwei": base_fee / 1e9 if base_fee is not None else None,
133
+ "priority_fee_p50_gwei": priority_p50 / 1e9 if priority_p50 is not None else None,
134
+ "native_price_usd": price,
135
+ "transfer_cost_usd": transfer_cost_usd,
136
+ }
137
+ text = f"{net.name} gas: {fmt_gwei(gas_price)}"
138
+ if base_fee is not None:
139
+ text += f" (base {fmt_gwei(base_fee)}"
140
+ text += f", priority p50 {fmt_gwei(priority_p50)})" if priority_p50 is not None else ")"
141
+ if transfer_cost_usd is not None:
142
+ text += f" — native transfer ≈ {fmt_usd(transfer_cost_usd)}"
143
+ out.emit(data, text, quiet_value=gas_price / 1e9, verbose_lines=[f"rpc: {client.url}"])
144
+
145
+
146
+ def tx(
147
+ tx_hash: Annotated[str, typer.Argument(help="transaction hash")],
148
+ network: NetworkOpt = "ethereum",
149
+ json_out: JsonOpt = False,
150
+ quiet: QuietOpt = False,
151
+ verbose: VerboseOpt = False,
152
+ format: FormatOpt = "text",
153
+ ):
154
+ """Look up a transaction: status, parties, value, fee, block."""
155
+ out = Out(json_out, quiet, verbose, format)
156
+ net = resolve_network(network)
157
+ client = connect(net)
158
+ try:
159
+ transaction = client.w3.eth.get_transaction(tx_hash)
160
+ except TransactionNotFound:
161
+ raise ChainqError(f"transaction {tx_hash} not found on {net.name} (check the hash and --network)") from None
162
+ receipt = None
163
+ if transaction.get("blockNumber") is not None:
164
+ try:
165
+ receipt = client.w3.eth.get_transaction_receipt(tx_hash)
166
+ except TransactionNotFound:
167
+ receipt = None
168
+ if receipt is None:
169
+ status = "pending"
170
+ else:
171
+ status = "success" if receipt["status"] == 1 else "failed"
172
+ value = Decimal(transaction["value"]) / Decimal(10**18)
173
+ fee = None
174
+ if receipt is not None:
175
+ fee = Decimal(receipt["gasUsed"] * receipt.get("effectiveGasPrice", transaction.get("gasPrice", 0))) / Decimal(10**18)
176
+ timestamp = None
177
+ if transaction.get("blockNumber") is not None:
178
+ block = client.w3.eth.get_block(transaction["blockNumber"])
179
+ timestamp = datetime.fromtimestamp(block["timestamp"], tz=UTC)
180
+ price = coingecko.try_price_usd(net.native_coingecko_id)
181
+ to_address = transaction.get("to")
182
+ data = {
183
+ "hash": transaction["hash"].hex(),
184
+ "network": net.key,
185
+ "status": status,
186
+ "from": transaction["from"],
187
+ "to": to_address,
188
+ "value": str(value),
189
+ "value_usd": float(value) * price if price is not None else None,
190
+ "fee": str(fee) if fee is not None else None,
191
+ "fee_usd": float(fee) * price if fee is not None and price is not None else None,
192
+ "block": transaction.get("blockNumber"),
193
+ "timestamp": timestamp.isoformat() if timestamp else None,
194
+ "nonce": transaction["nonce"],
195
+ "gas_used": receipt["gasUsed"] if receipt else None,
196
+ "gas_limit": transaction["gas"],
197
+ "explorer": f"{net.explorer}/tx/0x{transaction['hash'].hex().removeprefix('0x')}",
198
+ }
199
+ lines = [
200
+ f"Tx {short_addr(tx_hash)} on {net.name}: {status}",
201
+ f" {transaction['from']} → {to_address or 'contract creation'}",
202
+ f" value {fmt_amount(value)} {net.native_symbol}"
203
+ + (f" (~{fmt_usd(data['value_usd'])})" if data["value_usd"] else "")
204
+ + (f", fee {fmt_amount(fee)} {net.native_symbol}" if fee is not None else "")
205
+ + (f" (~{fmt_usd(data['fee_usd'])})" if data["fee_usd"] else ""),
206
+ ]
207
+ if data["block"] is not None:
208
+ when = f" at {timestamp.strftime('%Y-%m-%d %H:%M UTC')}" if timestamp else ""
209
+ lines.append(f" block {data['block']}{when}")
210
+ out.emit(
211
+ data,
212
+ lines,
213
+ quiet_value=status,
214
+ verbose_lines=[
215
+ f"nonce: {data['nonce']}, gas used: {data['gas_used']}/{data['gas_limit']}",
216
+ f"explorer: {data['explorer']}",
217
+ f"rpc: {client.url}",
218
+ ],
219
+ )
220
+
221
+
222
+ def rpc(
223
+ method: Annotated[str, typer.Argument(help="JSON-RPC method, e.g. eth_blockNumber")],
224
+ params: Annotated[list[str] | None, typer.Argument(help="params; JSON literals are parsed, rest stay strings")] = None,
225
+ network: NetworkOpt = "ethereum",
226
+ ):
227
+ """Raw JSON-RPC escape hatch; prints the JSON result."""
228
+ net = resolve_network(network)
229
+ client = connect(net)
230
+ parsed = []
231
+ for param in params or []:
232
+ try:
233
+ parsed.append(json.loads(param))
234
+ except json.JSONDecodeError:
235
+ parsed.append(param)
236
+ response = client.w3.provider.make_request(RPCEndpoint(method), parsed)
237
+ if "error" in response:
238
+ raise ChainqError(f"rpc error from {client.url}: {response['error']}")
239
+ print(json.dumps(response.get("result"), indent=2, default=str))
@@ -0,0 +1,105 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+ from chainq.errors import ChainqError
7
+
8
+ app = typer.Typer(no_args_is_help=True, help="Manage chainq configuration (stored in ~/.config/chainq/.env).")
9
+
10
+ CONFIG_PATH = Path.home() / ".config" / "chainq" / ".env"
11
+
12
+ KNOWN_KEYS = (
13
+ "COINGECKO_API_KEY",
14
+ "OPENSEA_API_KEY",
15
+ "CHAINQ_HTTP_TIMEOUT",
16
+ "CHAINQ_RPC_TIMEOUT",
17
+ "CHAINQ_NO_UPDATE_CHECK",
18
+ "CHAINQ_RPC_<NETWORK>",
19
+ )
20
+
21
+ SECRET_MARKERS = ("KEY", "SECRET", "TOKEN", "PASSWORD")
22
+
23
+
24
+ def _normalize(key: str) -> str:
25
+ return key.strip().upper().replace("-", "_")
26
+
27
+
28
+ def _read() -> dict[str, str]:
29
+ values: dict[str, str] = {}
30
+ if not CONFIG_PATH.exists():
31
+ return values
32
+ for line in CONFIG_PATH.read_text().splitlines():
33
+ line = line.strip()
34
+ if not line or line.startswith("#") or "=" not in line:
35
+ continue
36
+ key, _, value = line.partition("=")
37
+ values[key.strip()] = value.strip()
38
+ return values
39
+
40
+
41
+ def _write(values: dict[str, str]) -> None:
42
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
43
+ CONFIG_PATH.write_text("".join(f"{k}={v}\n" for k, v in values.items()))
44
+ CONFIG_PATH.chmod(0o600)
45
+
46
+
47
+ def _mask(key: str, value: str) -> str:
48
+ if not any(marker in key for marker in SECRET_MARKERS) or key == "CHAINQ_NO_UPDATE_CHECK":
49
+ return value
50
+ if len(value) > 8:
51
+ return f"{value[:4]}…{value[-4:]}"
52
+ return "****"
53
+
54
+
55
+ @app.command(name="set")
56
+ def set_(
57
+ key: Annotated[str, typer.Argument(help="e.g. coingecko-api-key, CHAINQ_RPC_ETHEREUM")],
58
+ value: Annotated[str, typer.Argument()],
59
+ ):
60
+ """Set a config value (applies to every future chainq run)."""
61
+ values = _read()
62
+ normalized = _normalize(key)
63
+ values[normalized] = value
64
+ _write(values)
65
+ print(f"{normalized}={_mask(normalized, value)} ({CONFIG_PATH})")
66
+
67
+
68
+ @app.command()
69
+ def get(key: Annotated[str, typer.Argument()]):
70
+ """Print one config value."""
71
+ values = _read()
72
+ normalized = _normalize(key)
73
+ if normalized not in values:
74
+ raise ChainqError(f"{normalized} is not set (see `chainq config list`)")
75
+ print(values[normalized])
76
+
77
+
78
+ @app.command()
79
+ def unset(key: Annotated[str, typer.Argument()]):
80
+ """Remove a config value."""
81
+ values = _read()
82
+ normalized = _normalize(key)
83
+ if normalized not in values:
84
+ raise ChainqError(f"{normalized} is not set")
85
+ del values[normalized]
86
+ _write(values)
87
+ print(f"removed {normalized}")
88
+
89
+
90
+ @app.command(name="list")
91
+ def list_(show_secrets: Annotated[bool, typer.Option("--show-secrets", help="print secret values in full")] = False):
92
+ """List configured values (secrets masked by default)."""
93
+ values = _read()
94
+ if not values:
95
+ print(f"no config set ({CONFIG_PATH})")
96
+ print(f"known keys: {', '.join(KNOWN_KEYS)}")
97
+ return
98
+ for key, value in values.items():
99
+ print(f"{key}={value if show_secrets else _mask(key, value)}")
100
+
101
+
102
+ @app.command()
103
+ def path():
104
+ """Print the config file path."""
105
+ print(CONFIG_PATH)
@@ -0,0 +1,40 @@
1
+ import typer
2
+
3
+ from chainq.fmt import fmt_pct, fmt_usd, humanize_usd
4
+ from chainq.output import FormatOpt, JsonOpt, Out, QuietOpt, VerboseOpt
5
+ from chainq.providers import defillama, ethena
6
+
7
+ app = typer.Typer(no_args_is_help=True, help="Ethena: sUSDe yield and USDe supply.")
8
+
9
+
10
+ def _pct(value: float | None) -> str:
11
+ return fmt_pct(value, signed=False)
12
+
13
+
14
+ @app.command(name="yield")
15
+ def yield_(
16
+ json_out: JsonOpt = False,
17
+ quiet: QuietOpt = False,
18
+ verbose: VerboseOpt = False,
19
+ format: FormatOpt = "text",
20
+ ):
21
+ """sUSDe staking APY, protocol yield, and USDe supply/peg."""
22
+ out = Out(json_out, quiet, verbose, format)
23
+ data = dict(ethena.yields())
24
+ usde = next((s for s in defillama.stablecoins() if s["symbol"] == "USDe"), None)
25
+ if usde:
26
+ data["usde_mcap_usd"] = usde["mcap_usd"]
27
+ data["usde_price_usd"] = usde["price_usd"]
28
+ lines = [
29
+ f"sUSDe yield: {_pct(data['susde_apy_pct'])} APY "
30
+ f"(30d avg {_pct(data['susde_apy_30d_pct'])}, 90d avg {_pct(data['susde_apy_90d_pct'])})",
31
+ f"protocol yield: {_pct(data['protocol_yield_pct'])} (30d avg {_pct(data['protocol_yield_30d_pct'])})",
32
+ ]
33
+ if usde:
34
+ lines.append(f"USDe supply: {humanize_usd(data['usde_mcap_usd'] or 0)}, price {fmt_usd(data['usde_price_usd'] or 0)}")
35
+ out.emit(
36
+ data,
37
+ lines,
38
+ quiet_value=data["susde_apy_pct"],
39
+ verbose_lines=[f"yield data updated: {data.get('updated')}", f"source: {ethena.YIELD_URL}"],
40
+ )