primecli 0.1.3__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primecli
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
5
5
  Author: Mnemosyne-quest contributors
6
6
  License: MIT
@@ -118,7 +118,7 @@ State-changing commands preview by default. Add `--execute` to broadcast.
118
118
 
119
119
  | Group | Commands |
120
120
  |-------|----------|
121
- | Lending core | `pool-info`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
121
+ | Lending core | `pool-info [--json]`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
122
122
  | Prime Account | `create-prime-account` (alias `create-account`), `prime-summary`, `defi --json`, `withdraw-collateral`, `withdrawal-intents`, `execute-withdrawal` |
123
123
  | Swaps | `swap --from S --to S --amount N [--via yak\|paraswap] [--slippage P]`, `swap-debt --from S --to S --amount N [--slippage P]` |
124
124
  | GMX V2 LP (async, keeper-executed) | `gmx-positions`, `gmx-deposit --market M --amount N [--side long\|short]`, `gmx-withdraw --market M --amount N` |
@@ -129,7 +129,7 @@ State-changing commands preview by default. Add `--execute` to broadcast.
129
129
 
130
130
  Pools: `usdc`, `wavax`, `weth`, `btc`, `usdt`. GM markets: `avax-usdc`, `btc-usdc`, `eth-usdc` (two-sided GM); `avax+`, `btc+`, `eth+` (single-sided GM+). LB pairs: `avax-usdc`, `avax-usdc-20`, `btc-usdc`, `eth-avax`, `btc-avax`, `avax-btc`, `eurc-usdc`, `usdt-usdc`, `joe-avax`.
131
131
 
132
- `defi --json` emits a full positions snapshot in a DeBank-like shape. `zap` composes `fund` → `borrow` → optional `swap` → `gmx-deposit` into one preview-and-execute flow, stopping on the first failure.
132
+ `defi --json` emits a full positions snapshot in a DeBank-like shape — `null`, empty-list, and empty-dict fields are stripped, and the decorative `url` key is dropped (numeric `0` and boolean `false` are preserved, since they carry real information). `pool-info --json` emits a single JSON object for a named pool, or a `{name: {...}}` dict for `all`. `zap` composes `fund` → `borrow` → optional `swap` → `gmx-deposit` into one preview-and-execute flow, stopping on the first failure.
133
133
 
134
134
  Full per-command reference: [docs/deltaprime-reference.md](docs/deltaprime-reference.md). Per-capability build spec: [docs/deltaprime-capabilities.md](docs/deltaprime-capabilities.md).
135
135
 
@@ -137,7 +137,7 @@ Full per-command reference: [docs/deltaprime-reference.md](docs/deltaprime-refer
137
137
 
138
138
  | Group | Commands |
139
139
  |-------|----------|
140
- | Lending core | `pool-info`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
140
+ | Lending core | `pool-info [--json]`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
141
141
  | Degen Account | `create-account`, `summary`, `withdraw-collateral`, `withdrawal-intents`, `execute-withdrawal`, `cancel-withdrawal` |
142
142
  | Swaps | `swap --from S --to S --amount N [--slippage P]` (ParaSwap v6), `swap-debt --from S --to S --amount N` |
143
143
  | Aerodrome (read-only in v1) | `aerodrome-positions` |
@@ -210,7 +210,7 @@ A copy-paste template is at [examples/env.example](examples/env.example).
210
210
  `primecli` is built for autonomous and semi-autonomous use. Three properties matter:
211
211
 
212
212
  1. **Preview by default.** Every state-changing command prints a structured preview and stops unless `--execute` is passed. An agent can call any command speculatively, parse the preview, decide whether to broadcast, then re-run with `--execute`.
213
- 2. **Predictable, parseable stdout.** Read-only commands (`pool-info`, `my-positions`, `prime-summary`, `summary`, `withdrawal-intents`, `lb-positions`, `gmx-positions`, `aerodrome-positions`, `sjoe-position`, `prime-tier`, `defi --json`) emit fixed-format tables or JSON. `defi --json` is a one-shot full positions snapshot.
213
+ 2. **Predictable, parseable stdout.** Read-only commands (`pool-info` (also `--json`), `my-positions`, `prime-summary`, `summary`, `withdrawal-intents`, `lb-positions`, `gmx-positions`, `aerodrome-positions`, `sjoe-position`, `prime-tier`, `defi --json`) emit fixed-format tables or JSON. `defi --json` is a one-shot full positions snapshot.
214
214
  3. **Clean failure modes.** Configuration errors do not print stack traces. A missing key prints `deltaprime: No signing key found. Set DELTAPRIME_PRIVATE_KEY ...` to stderr and exits 1.
215
215
 
216
216
  Full agent integration guide (Claude Code skill template, MCP notes, recommended guardrails): [docs/agent-integration.md](docs/agent-integration.md).
@@ -86,7 +86,7 @@ State-changing commands preview by default. Add `--execute` to broadcast.
86
86
 
87
87
  | Group | Commands |
88
88
  |-------|----------|
89
- | Lending core | `pool-info`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
89
+ | Lending core | `pool-info [--json]`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
90
90
  | Prime Account | `create-prime-account` (alias `create-account`), `prime-summary`, `defi --json`, `withdraw-collateral`, `withdrawal-intents`, `execute-withdrawal` |
91
91
  | Swaps | `swap --from S --to S --amount N [--via yak\|paraswap] [--slippage P]`, `swap-debt --from S --to S --amount N [--slippage P]` |
92
92
  | GMX V2 LP (async, keeper-executed) | `gmx-positions`, `gmx-deposit --market M --amount N [--side long\|short]`, `gmx-withdraw --market M --amount N` |
@@ -97,7 +97,7 @@ State-changing commands preview by default. Add `--execute` to broadcast.
97
97
 
98
98
  Pools: `usdc`, `wavax`, `weth`, `btc`, `usdt`. GM markets: `avax-usdc`, `btc-usdc`, `eth-usdc` (two-sided GM); `avax+`, `btc+`, `eth+` (single-sided GM+). LB pairs: `avax-usdc`, `avax-usdc-20`, `btc-usdc`, `eth-avax`, `btc-avax`, `avax-btc`, `eurc-usdc`, `usdt-usdc`, `joe-avax`.
99
99
 
100
- `defi --json` emits a full positions snapshot in a DeBank-like shape. `zap` composes `fund` → `borrow` → optional `swap` → `gmx-deposit` into one preview-and-execute flow, stopping on the first failure.
100
+ `defi --json` emits a full positions snapshot in a DeBank-like shape — `null`, empty-list, and empty-dict fields are stripped, and the decorative `url` key is dropped (numeric `0` and boolean `false` are preserved, since they carry real information). `pool-info --json` emits a single JSON object for a named pool, or a `{name: {...}}` dict for `all`. `zap` composes `fund` → `borrow` → optional `swap` → `gmx-deposit` into one preview-and-execute flow, stopping on the first failure.
101
101
 
102
102
  Full per-command reference: [docs/deltaprime-reference.md](docs/deltaprime-reference.md). Per-capability build spec: [docs/deltaprime-capabilities.md](docs/deltaprime-capabilities.md).
103
103
 
@@ -105,7 +105,7 @@ Full per-command reference: [docs/deltaprime-reference.md](docs/deltaprime-refer
105
105
 
106
106
  | Group | Commands |
107
107
  |-------|----------|
108
- | Lending core | `pool-info`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
108
+ | Lending core | `pool-info [--json]`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
109
109
  | Degen Account | `create-account`, `summary`, `withdraw-collateral`, `withdrawal-intents`, `execute-withdrawal`, `cancel-withdrawal` |
110
110
  | Swaps | `swap --from S --to S --amount N [--slippage P]` (ParaSwap v6), `swap-debt --from S --to S --amount N` |
111
111
  | Aerodrome (read-only in v1) | `aerodrome-positions` |
@@ -178,7 +178,7 @@ A copy-paste template is at [examples/env.example](examples/env.example).
178
178
  `primecli` is built for autonomous and semi-autonomous use. Three properties matter:
179
179
 
180
180
  1. **Preview by default.** Every state-changing command prints a structured preview and stops unless `--execute` is passed. An agent can call any command speculatively, parse the preview, decide whether to broadcast, then re-run with `--execute`.
181
- 2. **Predictable, parseable stdout.** Read-only commands (`pool-info`, `my-positions`, `prime-summary`, `summary`, `withdrawal-intents`, `lb-positions`, `gmx-positions`, `aerodrome-positions`, `sjoe-position`, `prime-tier`, `defi --json`) emit fixed-format tables or JSON. `defi --json` is a one-shot full positions snapshot.
181
+ 2. **Predictable, parseable stdout.** Read-only commands (`pool-info` (also `--json`), `my-positions`, `prime-summary`, `summary`, `withdrawal-intents`, `lb-positions`, `gmx-positions`, `aerodrome-positions`, `sjoe-position`, `prime-tier`, `defi --json`) emit fixed-format tables or JSON. `defi --json` is a one-shot full positions snapshot.
182
182
  3. **Clean failure modes.** Configuration errors do not print stack traces. A missing key prints `deltaprime: No signing key found. Set DELTAPRIME_PRIVATE_KEY ...` to stderr and exits 1.
183
183
 
184
184
  Full agent integration guide (Claude Code skill template, MCP notes, recommended guardrails): [docs/agent-integration.md](docs/agent-integration.md).
@@ -8,7 +8,7 @@ a per-user SmartLoan (diamond proxy) created via the SmartLoansFactory. The EOA
8
8
  it; borrow/repay/fund run on the Degen Account, which itself talks to the pools.
9
9
 
10
10
  Usage:
11
- degenprime pool-info [usdc|weth|cbbtc|aero|brett|kaito|cbdoge|cbxrp|all]
11
+ degenprime pool-info [usdc|weth|cbbtc|aero|brett|kaito|cbdoge|cbxrp|all] [--json]
12
12
  degenprime my-positions
13
13
  degenprime deposit --pool usdc --amount 100 [--execute]
14
14
  degenprime withdraw --pool usdc --amount 100 [--execute]
@@ -298,6 +298,45 @@ def get_pool_contract(pool_name: str):
298
298
  w3 = get_w3()
299
299
  return w3.eth.contract(address=proxy, abi=POOL_ABI), cfg, w3
300
300
 
301
+ # Multicall3 — deterministic deployment at the same address on every EVM chain
302
+ # (Avalanche C-chain and Base both have it). aggregate3(Call3[]) batches read-only
303
+ # calls into one eth_call; allowFailure=true per-call so a single revert returns
304
+ # success=false for that leg instead of blowing up the whole batch. Used to collapse
305
+ # per-pool / per-asset fan-out loops (cmd_pool_info("all"), cmd_summary, my-positions)
306
+ # from N RPCs into 1. Same address as DeltaPrime's; verified on chainlist.org and
307
+ # tested against the Base RPC.
308
+ MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11"
309
+ MULTICALL3_ABI = [
310
+ {"inputs": [{"components": [
311
+ {"name": "target", "type": "address"},
312
+ {"name": "allowFailure", "type": "bool"},
313
+ {"name": "callData", "type": "bytes"}], "type": "tuple[]", "name": "calls"}],
314
+ "name": "aggregate3",
315
+ "outputs": [{"components": [
316
+ {"name": "success", "type": "bool"},
317
+ {"name": "returnData", "type": "bytes"}], "type": "tuple[]", "name": "returnData"}],
318
+ "stateMutability": "view", "type": "function"},
319
+ ]
320
+
321
+ def multicall(w3, calls):
322
+ """Batch read-only calls via Multicall3.aggregate3. Each call is a (target_address,
323
+ calldata_bytes) tuple. Returns a list of (success, return_bytes) tuples in input
324
+ order — the caller is responsible for decoding return_bytes against the original
325
+ function's output types and treating success=False as a missing/reverted value. Empty
326
+ input returns []. The whole batch round-trips in one eth_call; gas is paid by the
327
+ simulated caller (zero address by default) so no key is required.
328
+
329
+ For RedStone-gated views: append the RedStone payload to each leg's calldata before
330
+ putting it in `calls`. Multicall3 only delegate-calls the target with the bytes you
331
+ provide; the on-chain solvency parser still reads the payload from the calldata tail
332
+ per leg."""
333
+ if not calls:
334
+ return []
335
+ mc = w3.eth.contract(address=Web3.to_checksum_address(MULTICALL3), abi=MULTICALL3_ABI)
336
+ args = [(Web3.to_checksum_address(t), True, d) for t, d in calls]
337
+ raw = mc.functions.aggregate3(args).call()
338
+ return [(bool(ok), bytes(rd)) for ok, rd in raw]
339
+
301
340
  # Minimal Degen Account ABI: only the facet functions this tool calls. The diamond
302
341
  # beacon's own ABI is beacon-management only, so the borrow/repay/fund and view
303
342
  # selectors live in facets - we hand-pick the verified signatures here rather than
@@ -595,85 +634,205 @@ def redstone_view_call(w3, account, fn_name: str, payload: bytes, args: list = N
595
634
 
596
635
  # ─── Commands ──────────────────────────────────────────────────────────────
597
636
 
598
- def cmd_pool_info(pool_name: str):
637
+ def _pool_info_data(pool_name: str) -> dict:
638
+ """Read every pool-info field for one pool in a SINGLE Multicall3 eth_call:
639
+ totalSupply, totalBorrowed, getDepositRate, getBorrowingRate, and (when a signer is
640
+ configured) the EOA's pool balance. Returns the raw + decoded values plus the
641
+ off-chain KuCoin USD price. Shared by the human-facing print path and the --json
642
+ path. cb-prefixed pool symbols fall back to their bare ticker for the KuCoin probe
643
+ (cbBTC -> BTC, cbDOGE -> DOGE, etc.)."""
644
+ contract, cfg, w3 = get_pool_contract(pool_name)
645
+ proxy_cs = Web3.to_checksum_address(cfg["proxy"])
646
+ legs = [
647
+ ("totalSupply", contract.encode_abi("totalSupply", args=[])),
648
+ ("totalBorrowed", contract.encode_abi("totalBorrowed", args=[])),
649
+ ("getDepositRate", contract.encode_abi("getDepositRate", args=[])),
650
+ ("getBorrowingRate", contract.encode_abi("getBorrowingRate", args=[])),
651
+ ]
652
+ try:
653
+ acct = get_account()
654
+ signer_addr = acct.address
655
+ legs.append(("balanceOf", contract.encode_abi("balanceOf", args=[signer_addr])))
656
+ except RuntimeError:
657
+ signer_addr = None
658
+ results = multicall(w3, [(proxy_cs, bytes.fromhex(d[2:]) if d.startswith("0x") else bytes.fromhex(d))
659
+ for _, d in legs])
660
+ out_types = {"totalSupply": ["uint256"], "totalBorrowed": ["uint256"],
661
+ "getDepositRate": ["uint256"], "getBorrowingRate": ["uint256"],
662
+ "balanceOf": ["uint256"]}
663
+ decoded = {}
664
+ for (name, _data), (ok, rd) in zip(legs, results):
665
+ if not ok or not rd:
666
+ decoded[name] = None
667
+ continue
668
+ try:
669
+ decoded[name] = w3.codec.decode(out_types[name], rd)[0]
670
+ except Exception:
671
+ decoded[name] = None
672
+ price_sym = cfg["symbol"].replace("cb", "") if cfg["symbol"].startswith("cb") else cfg["symbol"]
673
+ price = token_price(price_sym)
674
+ return {"name": pool_name, "cfg": cfg, "signer": signer_addr,
675
+ "raw": decoded, "price": price}
676
+
677
+
678
+ def _compact_num(value: float, places: int = 2) -> float:
679
+ """Round to `places` decimal places, defaulting to 2. Compact enough for an LLM
680
+ consumer without lying about the underlying number. Used for amount/USD fields in
681
+ pool-info --json."""
682
+ if value is None:
683
+ return None
684
+ return round(float(value), places)
685
+
686
+
687
+ def _pool_json_shape(data: dict) -> dict:
688
+ """Per-pool JSON object for `pool-info --json`. Same key names as deltaprime's
689
+ _pool_json_shape so an agent can consume both chains uniformly. Numbers are floats
690
+ rounded to 2 dp (amounts/USD/rates/utilization); proxy/token are full checksum
691
+ strings. Null-ish fields are omitted (no tokenPrice/tvl when KuCoin lookup fails,
692
+ no myDeposit without a key or with zero balance)."""
693
+ cfg, raw, price = data["cfg"], data["raw"], data["price"]
694
+ d = cfg["decimals"]
695
+ ts_raw, tb_raw = raw.get("totalSupply"), raw.get("totalBorrowed")
696
+ dr_raw, br_raw = raw.get("getDepositRate"), raw.get("getBorrowingRate")
697
+ ts = (ts_raw or 0) / 10**d
698
+ tb = (tb_raw or 0) / 10**d
699
+ util = (tb / ts * 100) if ts > 0 else 0.0
700
+ out = {
701
+ "symbol": cfg["symbol"],
702
+ "proxy": cfg["proxy"],
703
+ "token": cfg["token"],
704
+ "decimals": d,
705
+ "totalSupply": _compact_num(ts),
706
+ "totalBorrowed": _compact_num(tb),
707
+ "utilization": _compact_num(util),
708
+ }
709
+ if dr_raw is not None:
710
+ out["depositRate"] = _compact_num(dr_raw / 1e18 * 100)
711
+ if br_raw is not None:
712
+ out["borrowingRate"] = _compact_num(br_raw / 1e18 * 100)
713
+ if price:
714
+ out["tokenPrice"] = _compact_num(price, places=4)
715
+ out["tvl"] = _compact_num(ts * price)
716
+ my_bal = raw.get("balanceOf")
717
+ if my_bal is not None and my_bal > 0:
718
+ out["myDeposit"] = _compact_num(my_bal / 10**d, places=6)
719
+ return out
720
+
721
+
722
+ def cmd_pool_info(pool_name: str, as_json: bool = False):
723
+ """Print pool supply / borrow / utilization / APR / TVL for one pool or all.
724
+
725
+ Human-facing output (default) is unchanged. With --json: emits a single JSON object
726
+ for a named pool, or a {pool_name: {...}} dict for `all`. JSON shape matches
727
+ deltaprime so an agent can consume both chains uniformly. Numbers are floats (no
728
+ decoration), `tokenPrice` / `tvl` are omitted when KuCoin lookup fails, `myDeposit`
729
+ is omitted when no key is configured or the balance is zero."""
599
730
  if pool_name == "all":
731
+ if as_json:
732
+ out = {name: _pool_json_shape(_pool_info_data(name)) for name in POOLS}
733
+ print(json.dumps(out, indent=2))
734
+ return
600
735
  for name in POOLS:
601
736
  cmd_pool_info(name)
602
737
  print()
603
738
  return
604
739
 
605
- contract, cfg, w3 = get_pool_contract(pool_name)
740
+ data = _pool_info_data(pool_name)
741
+ if as_json:
742
+ print(json.dumps(_pool_json_shape(data), indent=2))
743
+ return
744
+
745
+ cfg, raw, price = data["cfg"], data["raw"], data["price"]
606
746
  p = cfg["proxy"][:12]
607
747
  d = cfg["decimals"]
608
-
609
- ts = contract.functions.totalSupply().call()
610
- tb = contract.functions.totalBorrowed().call()
748
+ ts = raw.get("totalSupply") or 0
749
+ tb = raw.get("totalBorrowed") or 0
611
750
  print(f"=== {cfg['symbol']} Pool ({p}...) ===")
612
751
  print(f" Total Supply: {ts / 10**d:>14,.2f} {cfg['symbol']}")
613
752
  print(f" Total Borrowed: {tb / 10**d:>14,.2f} {cfg['symbol']}")
614
753
  util = tb / ts * 100 if ts > 0 else 0
615
754
  print(f" Utilization: {util:>14.2f}%")
616
755
  # getDepositRate / getBorrowingRate are 1e18-scaled annualised rates.
617
- try:
618
- dr = contract.functions.getDepositRate().call() / 1e18 * 100
619
- br = contract.functions.getBorrowingRate().call() / 1e18 * 100
756
+ dr_raw, br_raw = raw.get("getDepositRate"), raw.get("getBorrowingRate")
757
+ if dr_raw is not None and br_raw is not None:
758
+ dr = dr_raw / 1e18 * 100
759
+ br = br_raw / 1e18 * 100
620
760
  print(f" Deposit APR: {dr:>14.2f}%")
621
761
  print(f" Borrow APR: {br:>14.2f}%")
622
- except Exception:
623
- pass
624
- # KuCoin doesn't trade cbBTC/cbDOGE/cbXRP directly - the cb-prefixed variants are
625
- # Coinbase wrapped versions; fall back to the underlying ticker for the price probe.
626
- price_sym = cfg["symbol"].replace("cb", "") if cfg["symbol"].startswith("cb") else cfg["symbol"]
627
- price = token_price(price_sym)
628
762
  if price:
629
763
  print(f" Token Price: ${price:>13,.2f}")
630
764
  print(f" TVL: ${ts / 10**d * price:>13,.2f}")
631
765
 
632
- # Show the signer's pool deposit when a key is configured; pool-info should
633
- # also work as a pure read-only command without one.
634
- try:
635
- acct = get_account()
636
- except RuntimeError:
637
- return
638
- my_bal = contract.functions.balanceOf(acct.address).call()
639
- if my_bal > 0:
766
+ # Show the signer's pool deposit when a key is configured; the balanceOf leg is
767
+ # read in the same multicall batch above we just print it here.
768
+ my_bal = raw.get("balanceOf")
769
+ if my_bal is not None and my_bal > 0:
640
770
  print(f" My Deposit: {my_bal / 10**d:.4f} {cfg['symbol']}")
641
771
 
642
772
  def cmd_my_positions():
643
773
  acct = get_account()
644
774
  w3 = get_w3()
775
+ # The Wallet: line MUST always print so the operator can verify the resolved
776
+ # signer address even when every other line is suppressed.
645
777
  print(f"Wallet: {acct.address}")
646
778
 
647
- # Wallet ETH (native Base asset, used for gas).
779
+ # Wallet ETH (native Base asset, used for gas). Suppress when below dust so a clean
780
+ # readout doesn't carry a noisy `ETH: 0.000000` line.
648
781
  eth = w3.eth.get_balance(acct.address) / 1e18
649
- print(f"ETH: {eth:.6f}")
650
-
782
+ if eth >= 1e-9:
783
+ print(f"ETH: {eth:.6f}")
784
+
785
+ # Batch every per-pool read into ONE Multicall3 eth_call: for each pool, the wallet
786
+ # ERC20 balanceOf + the pool balanceOf (the EOA's deposit) + the pool getBorrowed.
787
+ # Previously 3 RPCs per pool × 8 pools = 24 sequential round-trips; now 1 round-trip
788
+ # regardless of pool count.
789
+ legs = []
790
+ pool_meta = []
651
791
  for name, cfg in POOLS.items():
792
+ contract, _, _ = get_pool_contract(name)
793
+ token_cs = Web3.to_checksum_address(cfg["token"])
794
+ token = w3.eth.contract(address=token_cs, abi=ERC20_ABI)
795
+ proxy_cs = Web3.to_checksum_address(cfg["proxy"])
796
+ legs.append((token_cs, bytes.fromhex(token.encode_abi("balanceOf", args=[acct.address])[2:])))
797
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("balanceOf", args=[acct.address])[2:])))
798
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("getBorrowed", args=[acct.address])[2:])))
799
+ pool_meta.append((name, cfg))
800
+ try:
801
+ results = multicall(w3, legs)
802
+ except Exception as e:
803
+ print(f" pool reads failed via multicall: {type(e).__name__}: {e}")
804
+ results = [(False, b"")] * len(legs)
805
+ for i, (name, cfg) in enumerate(pool_meta):
806
+ wallet_ok, wallet_rd = results[i * 3]
807
+ pool_ok, pool_rd = results[i * 3 + 1]
808
+ borrow_ok, borrow_rd = results[i * 3 + 2]
652
809
  try:
653
- contract, _, _ = get_pool_contract(name)
654
- token_addr = Web3.to_checksum_address(cfg["token"])
655
- token = w3.eth.contract(address=token_addr, abi=ERC20_ABI)
656
- bal = token.functions.balanceOf(acct.address).call()
657
- if bal > 0:
658
- print(f" Wallet {cfg['symbol']}: {bal / 10**cfg['decimals']:.4f}")
659
-
660
- pool_bal = contract.functions.balanceOf(acct.address).call()
661
- if pool_bal > 0:
662
- print(f" Pool Deposit {cfg['symbol']}: {pool_bal / 10**cfg['decimals']:.4f}")
663
-
664
- borrowed = contract.functions.getBorrowed(acct.address).call()
665
- if borrowed > 0:
666
- print(f" Borrowed {cfg['symbol']}: {borrowed / 10**cfg['decimals']:.4f}")
667
-
810
+ wallet_bal = w3.codec.decode(["uint256"], wallet_rd)[0] if wallet_ok and wallet_rd else 0
811
+ pool_bal = w3.codec.decode(["uint256"], pool_rd)[0] if pool_ok and pool_rd else 0
812
+ borrowed = w3.codec.decode(["uint256"], borrow_rd)[0] if borrow_ok and borrow_rd else 0
668
813
  except Exception as e:
669
- print(f" {name}: {e}")
814
+ print(f" {name}: decode failed ({type(e).__name__})")
815
+ continue
816
+ if not wallet_ok:
817
+ print(f" {name}: wallet balanceOf leg reverted in multicall")
818
+ if not pool_ok:
819
+ print(f" {name}: pool balanceOf leg reverted in multicall")
820
+ if not borrow_ok:
821
+ print(f" {name}: getBorrowed leg reverted in multicall")
822
+ if wallet_bal > 0:
823
+ print(f" Wallet {cfg['symbol']}: {wallet_bal / 10**cfg['decimals']:.4f}")
824
+ if pool_bal > 0:
825
+ print(f" Pool Deposit {cfg['symbol']}: {pool_bal / 10**cfg['decimals']:.4f}")
826
+ if borrowed > 0:
827
+ print(f" Borrowed {cfg['symbol']}: {borrowed / 10**cfg['decimals']:.4f}")
670
828
 
671
829
  try:
672
830
  pa = get_prime_account(w3, acct.address)
673
831
  if pa:
674
832
  print(f"\nDegen Account: {pa}")
675
833
  pa_eth = w3.eth.get_balance(Web3.to_checksum_address(pa)) / 1e18
676
- print(f" ETH balance: {pa_eth:.6f}")
834
+ if pa_eth >= 1e-9:
835
+ print(f" ETH balance: {pa_eth:.6f}")
677
836
  else:
678
837
  print("\nNo Degen Account yet. Create with: degenprime create-account --execute")
679
838
  except Exception as e:
@@ -921,7 +1080,12 @@ def cmd_summary():
921
1080
  back to balances-only if the RedStone gateway is unreachable or a view reverts.
922
1081
  Note: per-asset USD is best-effort - only symbols with a RedStone primary-prod
923
1082
  feed are priced here. Symbols sourced on-chain from BaseOracle TWAP show as
924
- balance-only (the SolvencyFacet still values them for the total/debt figures)."""
1083
+ balance-only (the SolvencyFacet still values them for the total/debt figures).
1084
+
1085
+ Multicall: stage A batches getAllOwnedAssets + getDebts (2 -> 1 RPC). Stage B
1086
+ batches one getBalance per owned asset (N -> 1 RPC). Stage C batches the four
1087
+ RedStone-gated solvency views + getPrices (4-5 -> 1 RPC), each leg carrying the
1088
+ same RedStone payload appended."""
925
1089
  w3 = get_w3()
926
1090
  acct = get_account()
927
1091
  pa = get_prime_account(w3, acct.address)
@@ -935,13 +1099,27 @@ def cmd_summary():
935
1099
  print(f" Native ETH (gas): {pa_eth:.6f}")
936
1100
 
937
1101
  account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
938
- owned = [a.rstrip(b"\x00").decode(errors="replace") for a in account.functions.getAllOwnedAssets().call()]
1102
+ pa_cs = account.address
1103
+ stage_a_legs = [
1104
+ ("getAllOwnedAssets", ["bytes32[]"], account.encode_abi("getAllOwnedAssets", args=[])),
1105
+ ("getDebts", ["(bytes32,uint256)[]"], account.encode_abi("getDebts", args=[])),
1106
+ ]
1107
+ a_results = multicall(w3, [(pa_cs, bytes.fromhex(d[2:])) for _, _, d in stage_a_legs])
1108
+ owned_raw = w3.codec.decode(["bytes32[]"], a_results[0][1])[0] if a_results[0][0] else []
1109
+ debts_raw = w3.codec.decode(["(bytes32,uint256)[]"], a_results[1][1])[0] if a_results[1][0] else []
1110
+ owned = [a.rstrip(b"\x00").decode(errors="replace") for a in owned_raw]
1111
+ if owned:
1112
+ bal_legs = [(pa_cs, bytes.fromhex(account.encode_abi("getBalance", args=[asset_b32(sym)])[2:]))
1113
+ for sym in owned]
1114
+ bal_results = multicall(w3, bal_legs)
1115
+ else:
1116
+ bal_results = []
939
1117
  supplied = []
940
- for sym in owned:
941
- bal = account.functions.getBalance(asset_b32(sym)).call()
1118
+ for sym, (ok, rd) in zip(owned, bal_results):
1119
+ bal = w3.codec.decode(["uint256"], rd)[0] if ok and rd else 0
942
1120
  supplied.append({"symbol": sym, "raw": bal, "decimals": _asset_decimals(w3, sym)})
943
1121
  borrowed = []
944
- for n, v in account.functions.getDebts().call():
1122
+ for n, v in debts_raw:
945
1123
  sym = n.rstrip(b"\x00").decode(errors="replace")
946
1124
  if v > 0:
947
1125
  borrowed.append({"symbol": sym, "raw": v, "decimals": _asset_decimals(w3, sym)})
@@ -950,18 +1128,54 @@ def cmd_summary():
950
1128
  # without signed price calldata appended. Fetch a fresh RedStone payload covering
951
1129
  # every feed the solvency math touches (RedStone-feed symbols only - others come
952
1130
  # from BaseOracle on-chain) and eth_call the views with it appended. No tx.
1131
+ # Each leg in the multicall carries the same payload — redundant on the wire,
1132
+ # but correct: the SolvencyFacet parses the payload from the calldata tail per leg.
953
1133
  solvency = {"total": None, "debt": None, "ratio": None, "solvent": None, "error": None, "prices": {}}
954
1134
  try:
955
1135
  feeds = degen_account_price_feeds(account)
956
1136
  payload = build_redstone_payload(feeds)
957
- solvency["total"] = redstone_view_call(w3, account, "getTotalValue", payload)[0] / 1e18
958
- solvency["debt"] = redstone_view_call(w3, account, "getDebt", payload)[0] / 1e18
959
- ratio = redstone_view_call(w3, account, "getHealthRatio", payload)[0] / 1e18
960
- # With negligible debt the ratio is astronomically large (e.g. 1e59) - render
961
- # that as None and show ">1000" rather than a junk number.
962
- solvency["ratio"] = None if ratio > 1000 else ratio
963
- solvency["solvent"] = bool(redstone_view_call(w3, account, "isSolvent", payload)[0])
964
- solvency["prices"] = _prices_usd(w3, account, [r["symbol"] for r in supplied + borrowed], payload)
1137
+ payload_hex = payload.hex()
1138
+ price_syms = [s for s in dict.fromkeys(r["symbol"] for r in supplied + borrowed)
1139
+ if s and s in REDSTONE_AVAILABLE_FEEDS]
1140
+ solv_legs = [
1141
+ ("getTotalValue", ["uint256"], account.encode_abi("getTotalValue", args=[])),
1142
+ ("getDebt", ["uint256"], account.encode_abi("getDebt", args=[])),
1143
+ ("getHealthRatio", ["uint256"], account.encode_abi("getHealthRatio", args=[])),
1144
+ ("isSolvent", ["bool"], account.encode_abi("isSolvent", args=[])),
1145
+ ]
1146
+ if price_syms:
1147
+ solv_legs.append(("getPrices", ["uint256[]"],
1148
+ account.encode_abi("getPrices",
1149
+ args=[[asset_b32(s) for s in price_syms]])))
1150
+ solv_results = multicall(w3, [(pa_cs, bytes.fromhex(d[2:]) + bytes.fromhex(payload_hex))
1151
+ for _, _, d in solv_legs])
1152
+ decoded_solv = {}
1153
+ for (name, out_types, _d), (ok, rd) in zip(solv_legs, solv_results):
1154
+ if not ok or not rd:
1155
+ decoded_solv[name] = None
1156
+ continue
1157
+ try:
1158
+ decoded_solv[name] = w3.codec.decode(out_types, rd)[0]
1159
+ except Exception:
1160
+ decoded_solv[name] = None
1161
+ if decoded_solv.get("getTotalValue") is not None:
1162
+ solvency["total"] = decoded_solv["getTotalValue"] / 1e18
1163
+ if decoded_solv.get("getDebt") is not None:
1164
+ solvency["debt"] = decoded_solv["getDebt"] / 1e18
1165
+ if decoded_solv.get("getHealthRatio") is not None:
1166
+ ratio = decoded_solv["getHealthRatio"] / 1e18
1167
+ # With negligible debt the ratio is astronomically large (e.g. 1e59) - render
1168
+ # that as None and show ">1000" rather than a junk number.
1169
+ solvency["ratio"] = None if ratio > 1000 else ratio
1170
+ if decoded_solv.get("isSolvent") is not None:
1171
+ solvency["solvent"] = bool(decoded_solv["isSolvent"])
1172
+ prices = {}
1173
+ if price_syms and decoded_solv.get("getPrices") is not None:
1174
+ raw_prices = decoded_solv["getPrices"]
1175
+ for i, s in enumerate(price_syms):
1176
+ if i < len(raw_prices):
1177
+ prices[s] = raw_prices[i] / 1e8
1178
+ solvency["prices"] = prices
965
1179
  except Exception as e:
966
1180
  solvency["error"] = type(e).__name__
967
1181
 
@@ -1735,11 +1949,16 @@ def _dispatch():
1735
1949
 
1736
1950
  cmd = args[0]
1737
1951
  if cmd == "pool-info":
1738
- pool = args[1] if len(args) > 1 else "all"
1952
+ # First positional after `pool-info` is the pool name; --json is an opt-in flag
1953
+ # that switches output from human tables to a compact JSON shape (one object for
1954
+ # a named pool, dict-of-objects for `all`).
1955
+ as_json = "--json" in args
1956
+ positional = [a for a in args[1:] if not a.startswith("--")]
1957
+ pool = positional[0] if positional else "all"
1739
1958
  if pool != "all" and pool not in POOLS:
1740
1959
  print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}, all")
1741
1960
  return
1742
- cmd_pool_info(pool)
1961
+ cmd_pool_info(pool, as_json)
1743
1962
  elif cmd == "my-positions":
1744
1963
  cmd_my_positions()
1745
1964
  elif cmd == "deposit":