primecli 0.1.3__tar.gz → 0.2.1__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.1
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,8 +137,8 @@ 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` |
141
- | Degen Account | `create-account`, `summary`, `withdraw-collateral`, `withdrawal-intents`, `execute-withdrawal`, `cancel-withdrawal` |
140
+ | Lending core | `pool-info [--json]`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
141
+ | Degen Account | `create-account`, `summary [--json]`, `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` |
144
144
 
@@ -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,8 +105,8 @@ 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` |
109
- | Degen Account | `create-account`, `summary`, `withdraw-collateral`, `withdrawal-intents`, `execute-withdrawal`, `cancel-withdrawal` |
108
+ | Lending core | `pool-info [--json]`, `my-positions`, `deposit`, `withdraw`, `borrow`, `repay`, `fund` |
109
+ | Degen Account | `create-account`, `summary [--json]`, `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` |
112
112
 
@@ -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,210 @@ 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
+ out = {
698
+ "symbol": cfg["symbol"],
699
+ "proxy": cfg["proxy"],
700
+ "token": cfg["token"],
701
+ "decimals": d,
702
+ }
703
+ # Omit any field whose multicall leg returned None (revert / decode failure).
704
+ # That lets a downstream consumer tell "this pool's read failed" from "this
705
+ # pool is at literally 0".
706
+ if ts_raw is not None:
707
+ out["totalSupply"] = _compact_num(ts_raw / 10**d)
708
+ if tb_raw is not None:
709
+ out["totalBorrowed"] = _compact_num(tb_raw / 10**d)
710
+ if ts_raw is not None and tb_raw is not None:
711
+ ts = ts_raw / 10**d
712
+ util = (tb_raw / 10**d / ts * 100) if ts > 0 else 0.0
713
+ out["utilization"] = _compact_num(util)
714
+ if dr_raw is not None:
715
+ out["depositRate"] = _compact_num(dr_raw / 1e18 * 100)
716
+ if br_raw is not None:
717
+ out["borrowingRate"] = _compact_num(br_raw / 1e18 * 100)
718
+ if price and ts_raw is not None:
719
+ out["tokenPrice"] = _compact_num(price, places=4)
720
+ out["tvl"] = _compact_num(ts_raw / 10**d * price)
721
+ my_bal = raw.get("balanceOf")
722
+ if my_bal is not None and my_bal > 0:
723
+ out["myDeposit"] = _compact_num(my_bal / 10**d, places=6)
724
+ return out
725
+
726
+
727
+ def cmd_pool_info(pool_name: str, as_json: bool = False):
728
+ """Print pool supply / borrow / utilization / APR / TVL for one pool or all.
729
+
730
+ Human-facing output (default) is unchanged. With --json: emits a single JSON object
731
+ for a named pool, or a {pool_name: {...}} dict for `all`. JSON shape matches
732
+ deltaprime so an agent can consume both chains uniformly. Numbers are floats (no
733
+ decoration), `tokenPrice` / `tvl` are omitted when KuCoin lookup fails, `myDeposit`
734
+ is omitted when no key is configured or the balance is zero."""
599
735
  if pool_name == "all":
736
+ if as_json:
737
+ out = {name: _pool_json_shape(_pool_info_data(name)) for name in POOLS}
738
+ print(json.dumps(out, indent=2))
739
+ return
600
740
  for name in POOLS:
601
741
  cmd_pool_info(name)
602
742
  print()
603
743
  return
604
744
 
605
- contract, cfg, w3 = get_pool_contract(pool_name)
745
+ data = _pool_info_data(pool_name)
746
+ if as_json:
747
+ print(json.dumps(_pool_json_shape(data), indent=2))
748
+ return
749
+
750
+ cfg, raw, price = data["cfg"], data["raw"], data["price"]
606
751
  p = cfg["proxy"][:12]
607
752
  d = cfg["decimals"]
608
-
609
- ts = contract.functions.totalSupply().call()
610
- tb = contract.functions.totalBorrowed().call()
753
+ ts = raw.get("totalSupply") or 0
754
+ tb = raw.get("totalBorrowed") or 0
611
755
  print(f"=== {cfg['symbol']} Pool ({p}...) ===")
612
756
  print(f" Total Supply: {ts / 10**d:>14,.2f} {cfg['symbol']}")
613
757
  print(f" Total Borrowed: {tb / 10**d:>14,.2f} {cfg['symbol']}")
614
758
  util = tb / ts * 100 if ts > 0 else 0
615
759
  print(f" Utilization: {util:>14.2f}%")
616
760
  # 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
761
+ dr_raw, br_raw = raw.get("getDepositRate"), raw.get("getBorrowingRate")
762
+ if dr_raw is not None and br_raw is not None:
763
+ dr = dr_raw / 1e18 * 100
764
+ br = br_raw / 1e18 * 100
620
765
  print(f" Deposit APR: {dr:>14.2f}%")
621
766
  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
767
  if price:
629
768
  print(f" Token Price: ${price:>13,.2f}")
630
769
  print(f" TVL: ${ts / 10**d * price:>13,.2f}")
631
770
 
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:
771
+ # Show the signer's pool deposit when a key is configured; the balanceOf leg is
772
+ # read in the same multicall batch above we just print it here.
773
+ my_bal = raw.get("balanceOf")
774
+ if my_bal is not None and my_bal > 0:
640
775
  print(f" My Deposit: {my_bal / 10**d:.4f} {cfg['symbol']}")
641
776
 
642
777
  def cmd_my_positions():
643
778
  acct = get_account()
644
779
  w3 = get_w3()
780
+ # The Wallet: line MUST always print so the operator can verify the resolved
781
+ # signer address even when every other line is suppressed.
645
782
  print(f"Wallet: {acct.address}")
646
783
 
647
- # Wallet ETH (native Base asset, used for gas).
784
+ # Wallet ETH (native Base asset, used for gas). Suppress when below dust so a clean
785
+ # readout doesn't carry a noisy `ETH: 0.000000` line.
648
786
  eth = w3.eth.get_balance(acct.address) / 1e18
649
- print(f"ETH: {eth:.6f}")
650
-
787
+ if eth >= 1e-9:
788
+ print(f"ETH: {eth:.6f}")
789
+
790
+ # Batch every per-pool read into ONE Multicall3 eth_call: for each pool, the wallet
791
+ # ERC20 balanceOf + the pool balanceOf (the EOA's deposit) + the pool getBorrowed.
792
+ # Previously 3 RPCs per pool × 8 pools = 24 sequential round-trips; now 1 round-trip
793
+ # regardless of pool count.
794
+ legs = []
795
+ pool_meta = []
651
796
  for name, cfg in POOLS.items():
797
+ contract, _, _ = get_pool_contract(name)
798
+ token_cs = Web3.to_checksum_address(cfg["token"])
799
+ token = w3.eth.contract(address=token_cs, abi=ERC20_ABI)
800
+ proxy_cs = Web3.to_checksum_address(cfg["proxy"])
801
+ legs.append((token_cs, bytes.fromhex(token.encode_abi("balanceOf", args=[acct.address])[2:])))
802
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("balanceOf", args=[acct.address])[2:])))
803
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("getBorrowed", args=[acct.address])[2:])))
804
+ pool_meta.append((name, cfg))
805
+ try:
806
+ results = multicall(w3, legs)
807
+ except Exception as e:
808
+ print(f" pool reads failed via multicall: {type(e).__name__}: {e}")
809
+ results = [(False, b"")] * len(legs)
810
+ for i, (name, cfg) in enumerate(pool_meta):
811
+ wallet_ok, wallet_rd = results[i * 3]
812
+ pool_ok, pool_rd = results[i * 3 + 1]
813
+ borrow_ok, borrow_rd = results[i * 3 + 2]
652
814
  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
-
815
+ wallet_bal = w3.codec.decode(["uint256"], wallet_rd)[0] if wallet_ok and wallet_rd else 0
816
+ pool_bal = w3.codec.decode(["uint256"], pool_rd)[0] if pool_ok and pool_rd else 0
817
+ borrowed = w3.codec.decode(["uint256"], borrow_rd)[0] if borrow_ok and borrow_rd else 0
668
818
  except Exception as e:
669
- print(f" {name}: {e}")
819
+ print(f" {name}: decode failed ({type(e).__name__})")
820
+ continue
821
+ if not wallet_ok:
822
+ print(f" {name}: wallet balanceOf leg reverted in multicall")
823
+ if not pool_ok:
824
+ print(f" {name}: pool balanceOf leg reverted in multicall")
825
+ if not borrow_ok:
826
+ print(f" {name}: getBorrowed leg reverted in multicall")
827
+ if wallet_bal > 0:
828
+ print(f" Wallet {cfg['symbol']}: {wallet_bal / 10**cfg['decimals']:.4f}")
829
+ if pool_bal > 0:
830
+ print(f" Pool Deposit {cfg['symbol']}: {pool_bal / 10**cfg['decimals']:.4f}")
831
+ if borrowed > 0:
832
+ print(f" Borrowed {cfg['symbol']}: {borrowed / 10**cfg['decimals']:.4f}")
670
833
 
671
834
  try:
672
835
  pa = get_prime_account(w3, acct.address)
673
836
  if pa:
674
837
  print(f"\nDegen Account: {pa}")
675
838
  pa_eth = w3.eth.get_balance(Web3.to_checksum_address(pa)) / 1e18
676
- print(f" ETH balance: {pa_eth:.6f}")
839
+ if pa_eth >= 1e-9:
840
+ print(f" ETH balance: {pa_eth:.6f}")
677
841
  else:
678
842
  print("\nNo Degen Account yet. Create with: degenprime create-account --execute")
679
843
  except Exception as e:
@@ -915,33 +1079,64 @@ def _prices_usd(w3, account, symbols: list, payload: bytes) -> dict:
915
1079
  except Exception:
916
1080
  return {}
917
1081
 
918
- def cmd_summary():
1082
+ def cmd_summary(as_json: bool = False):
919
1083
  """Read-only Degen Account view: in-account collateral, debts, and live
920
1084
  RedStone-gated solvency (getTotalValue/getDebt/getHealthRatio/isSolvent). Falls
921
1085
  back to balances-only if the RedStone gateway is unreachable or a view reverts.
922
1086
  Note: per-asset USD is best-effort - only symbols with a RedStone primary-prod
923
1087
  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)."""
1088
+ balance-only (the SolvencyFacet still values them for the total/debt figures).
1089
+
1090
+ With --json: emits a single JSON object covering wallet, account, native
1091
+ balance, per-asset supplied/borrowed with optional USD, total/debt/health-ratio/
1092
+ solvent flags. Null fields, empty lists, and empty dicts are dropped (same
1093
+ trim contract as `deltaprime defi --json`). Numeric 0 and boolean false are
1094
+ preserved.
1095
+
1096
+ Multicall: stage A batches getAllOwnedAssets + getDebts (2 -> 1 RPC). Stage B
1097
+ batches one getBalance per owned asset (N -> 1 RPC). Stage C batches the four
1098
+ RedStone-gated solvency views + getPrices (4-5 -> 1 RPC), each leg carrying the
1099
+ same RedStone payload appended."""
925
1100
  w3 = get_w3()
926
1101
  acct = get_account()
927
1102
  pa = get_prime_account(w3, acct.address)
928
- print(f"Wallet: {acct.address}")
1103
+ if not as_json:
1104
+ print(f"Wallet: {acct.address}")
929
1105
  if not pa:
930
- print("No Degen Account yet. Create one with: degenprime create-account --execute")
1106
+ if as_json:
1107
+ print(json.dumps({"wallet": acct.address, "account": None}, indent=2))
1108
+ else:
1109
+ print("No Degen Account yet. Create one with: degenprime create-account --execute")
931
1110
  return
932
1111
 
933
- print(f"Degen Account: {pa}")
934
1112
  pa_eth = w3.eth.get_balance(pa) / 1e18
935
- print(f" Native ETH (gas): {pa_eth:.6f}")
1113
+ if not as_json:
1114
+ print(f"Degen Account: {pa}")
1115
+ if pa_eth >= 1e-9:
1116
+ print(f" Native ETH (gas): {pa_eth:.6f}")
936
1117
 
937
1118
  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()]
1119
+ pa_cs = account.address
1120
+ stage_a_legs = [
1121
+ ("getAllOwnedAssets", ["bytes32[]"], account.encode_abi("getAllOwnedAssets", args=[])),
1122
+ ("getDebts", ["(bytes32,uint256)[]"], account.encode_abi("getDebts", args=[])),
1123
+ ]
1124
+ a_results = multicall(w3, [(pa_cs, bytes.fromhex(d[2:])) for _, _, d in stage_a_legs])
1125
+ owned_raw = w3.codec.decode(["bytes32[]"], a_results[0][1])[0] if a_results[0][0] else []
1126
+ debts_raw = w3.codec.decode(["(bytes32,uint256)[]"], a_results[1][1])[0] if a_results[1][0] else []
1127
+ owned = [a.rstrip(b"\x00").decode(errors="replace") for a in owned_raw]
1128
+ if owned:
1129
+ bal_legs = [(pa_cs, bytes.fromhex(account.encode_abi("getBalance", args=[asset_b32(sym)])[2:]))
1130
+ for sym in owned]
1131
+ bal_results = multicall(w3, bal_legs)
1132
+ else:
1133
+ bal_results = []
939
1134
  supplied = []
940
- for sym in owned:
941
- bal = account.functions.getBalance(asset_b32(sym)).call()
1135
+ for sym, (ok, rd) in zip(owned, bal_results):
1136
+ bal = w3.codec.decode(["uint256"], rd)[0] if ok and rd else 0
942
1137
  supplied.append({"symbol": sym, "raw": bal, "decimals": _asset_decimals(w3, sym)})
943
1138
  borrowed = []
944
- for n, v in account.functions.getDebts().call():
1139
+ for n, v in debts_raw:
945
1140
  sym = n.rstrip(b"\x00").decode(errors="replace")
946
1141
  if v > 0:
947
1142
  borrowed.append({"symbol": sym, "raw": v, "decimals": _asset_decimals(w3, sym)})
@@ -950,21 +1145,83 @@ def cmd_summary():
950
1145
  # without signed price calldata appended. Fetch a fresh RedStone payload covering
951
1146
  # every feed the solvency math touches (RedStone-feed symbols only - others come
952
1147
  # from BaseOracle on-chain) and eth_call the views with it appended. No tx.
1148
+ # Each leg in the multicall carries the same payload — redundant on the wire,
1149
+ # but correct: the SolvencyFacet parses the payload from the calldata tail per leg.
953
1150
  solvency = {"total": None, "debt": None, "ratio": None, "solvent": None, "error": None, "prices": {}}
954
1151
  try:
955
1152
  feeds = degen_account_price_feeds(account)
956
1153
  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)
1154
+ payload_hex = payload.hex()
1155
+ price_syms = [s for s in dict.fromkeys(r["symbol"] for r in supplied + borrowed)
1156
+ if s and s in REDSTONE_AVAILABLE_FEEDS]
1157
+ solv_legs = [
1158
+ ("getTotalValue", ["uint256"], account.encode_abi("getTotalValue", args=[])),
1159
+ ("getDebt", ["uint256"], account.encode_abi("getDebt", args=[])),
1160
+ ("getHealthRatio", ["uint256"], account.encode_abi("getHealthRatio", args=[])),
1161
+ ("isSolvent", ["bool"], account.encode_abi("isSolvent", args=[])),
1162
+ ]
1163
+ if price_syms:
1164
+ solv_legs.append(("getPrices", ["uint256[]"],
1165
+ account.encode_abi("getPrices",
1166
+ args=[[asset_b32(s) for s in price_syms]])))
1167
+ solv_results = multicall(w3, [(pa_cs, bytes.fromhex(d[2:]) + bytes.fromhex(payload_hex))
1168
+ for _, _, d in solv_legs])
1169
+ decoded_solv = {}
1170
+ for (name, out_types, _d), (ok, rd) in zip(solv_legs, solv_results):
1171
+ if not ok or not rd:
1172
+ decoded_solv[name] = None
1173
+ continue
1174
+ try:
1175
+ decoded_solv[name] = w3.codec.decode(out_types, rd)[0]
1176
+ except Exception:
1177
+ decoded_solv[name] = None
1178
+ if decoded_solv.get("getTotalValue") is not None:
1179
+ solvency["total"] = decoded_solv["getTotalValue"] / 1e18
1180
+ if decoded_solv.get("getDebt") is not None:
1181
+ solvency["debt"] = decoded_solv["getDebt"] / 1e18
1182
+ if decoded_solv.get("getHealthRatio") is not None:
1183
+ ratio = decoded_solv["getHealthRatio"] / 1e18
1184
+ # With negligible debt the ratio is astronomically large (e.g. 1e59) - render
1185
+ # that as None and show ">1000" rather than a junk number.
1186
+ solvency["ratio"] = None if ratio > 1000 else ratio
1187
+ if decoded_solv.get("isSolvent") is not None:
1188
+ solvency["solvent"] = bool(decoded_solv["isSolvent"])
1189
+ prices = {}
1190
+ if price_syms and decoded_solv.get("getPrices") is not None:
1191
+ raw_prices = decoded_solv["getPrices"]
1192
+ for i, s in enumerate(price_syms):
1193
+ if i < len(raw_prices):
1194
+ prices[s] = raw_prices[i] / 1e8
1195
+ solvency["prices"] = prices
965
1196
  except Exception as e:
966
1197
  solvency["error"] = type(e).__name__
967
1198
 
1199
+ if as_json:
1200
+ def _asset_row(r):
1201
+ row = {"symbol": r["symbol"], "amount": r["raw"] / 10**r["decimals"]}
1202
+ usd = solvency["prices"].get(r["symbol"])
1203
+ if usd is not None:
1204
+ row["usd"] = round(row["amount"] * usd, 2)
1205
+ return row
1206
+
1207
+ out = {
1208
+ "wallet": acct.address,
1209
+ "account": pa,
1210
+ "nativeBalance": pa_eth if pa_eth >= 1e-9 else None,
1211
+ "supplied": [_asset_row(r) for r in supplied],
1212
+ "borrowed": [_asset_row(r) for r in borrowed],
1213
+ "totalValueUsd": solvency["total"],
1214
+ "debtUsd": solvency["debt"],
1215
+ "healthRatio": solvency["ratio"],
1216
+ "solvent": solvency["solvent"],
1217
+ "solvencyError": solvency["error"],
1218
+ }
1219
+ # Drop None / empty list / empty dict; preserve 0 and False.
1220
+ out = {k: v for k, v in out.items()
1221
+ if not (v is None or v == [] or v == {})}
1222
+ print(json.dumps(out, indent=2))
1223
+ return
1224
+
968
1225
  print(" Assets:")
969
1226
  if supplied:
970
1227
  for r in supplied:
@@ -1735,11 +1992,16 @@ def _dispatch():
1735
1992
 
1736
1993
  cmd = args[0]
1737
1994
  if cmd == "pool-info":
1738
- pool = args[1] if len(args) > 1 else "all"
1995
+ # First positional after `pool-info` is the pool name; --json is an opt-in flag
1996
+ # that switches output from human tables to a compact JSON shape (one object for
1997
+ # a named pool, dict-of-objects for `all`).
1998
+ as_json = "--json" in args
1999
+ positional = [a for a in args[1:] if not a.startswith("--")]
2000
+ pool = positional[0] if positional else "all"
1739
2001
  if pool != "all" and pool not in POOLS:
1740
2002
  print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}, all")
1741
2003
  return
1742
- cmd_pool_info(pool)
2004
+ cmd_pool_info(pool, as_json)
1743
2005
  elif cmd == "my-positions":
1744
2006
  cmd_my_positions()
1745
2007
  elif cmd == "deposit":
@@ -1781,7 +2043,7 @@ def _dispatch():
1781
2043
  return
1782
2044
  cmd_create_account("--execute" in args, fund_pool, fund_amount)
1783
2045
  elif cmd == "summary":
1784
- cmd_summary()
2046
+ cmd_summary(as_json="--json" in args)
1785
2047
  elif cmd == "fund":
1786
2048
  pool, amount = None, None
1787
2049
  execute = "--execute" in args