primecli 0.5.1__tar.gz → 0.5.3__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.
Files changed (22) hide show
  1. {primecli-0.5.1 → primecli-0.5.3}/PKG-INFO +3 -4
  2. {primecli-0.5.1 → primecli-0.5.3}/README.md +1 -1
  3. {primecli-0.5.1 → primecli-0.5.3}/primecli/arbprime.py +70 -1
  4. {primecli-0.5.1 → primecli-0.5.3}/primecli/degenprime.py +173 -51
  5. {primecli-0.5.1 → primecli-0.5.3}/primecli/deltaprime.py +91 -13
  6. {primecli-0.5.1 → primecli-0.5.3}/primecli/health_monitor.py +33 -0
  7. {primecli-0.5.1 → primecli-0.5.3}/primecli.egg-info/PKG-INFO +3 -4
  8. {primecli-0.5.1 → primecli-0.5.3}/pyproject.toml +2 -2
  9. {primecli-0.5.1 → primecli-0.5.3}/LICENSE +0 -0
  10. {primecli-0.5.1 → primecli-0.5.3}/primecli/__init__.py +0 -0
  11. {primecli-0.5.1 → primecli-0.5.3}/primecli.egg-info/SOURCES.txt +0 -0
  12. {primecli-0.5.1 → primecli-0.5.3}/primecli.egg-info/dependency_links.txt +0 -0
  13. {primecli-0.5.1 → primecli-0.5.3}/primecli.egg-info/entry_points.txt +0 -0
  14. {primecli-0.5.1 → primecli-0.5.3}/primecli.egg-info/requires.txt +0 -0
  15. {primecli-0.5.1 → primecli-0.5.3}/primecli.egg-info/top_level.txt +0 -0
  16. {primecli-0.5.1 → primecli-0.5.3}/setup.cfg +0 -0
  17. {primecli-0.5.1 → primecli-0.5.3}/tests/test_cross_file_identity.py +0 -0
  18. {primecli-0.5.1 → primecli-0.5.3}/tests/test_gas_pricing.py +0 -0
  19. {primecli-0.5.1 → primecli-0.5.3}/tests/test_health_monitor.py +0 -0
  20. {primecli-0.5.1 → primecli-0.5.3}/tests/test_paraswap_validator.py +0 -0
  21. {primecli-0.5.1 → primecli-0.5.3}/tests/test_redstone_encoding.py +0 -0
  22. {primecli-0.5.1 → primecli-0.5.3}/tests/test_to_wei_units.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
5
5
  Author: Mnemosyne-quest contributors
6
6
  License: MIT
@@ -28,7 +28,6 @@ Requires-Dist: eth-account>=0.13
28
28
  Requires-Dist: eth-keys>=0.5
29
29
  Requires-Dist: eth-abi<7,>=5.0
30
30
  Requires-Dist: requests>=2.31
31
- Dynamic: license-file
32
31
 
33
32
  # primecli
34
33
 
@@ -48,7 +47,7 @@ Built for agent use:
48
47
  - RedStone-signed solvency math handled internally, with a regression test pinning the half-boundary `toFixed(8)` encoding.
49
48
  - ParaSwap calldata validated client-side against the on-chain executor allowlist before broadcast.
50
49
 
51
- **Current version:** 0.5.1 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.5.2 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
52
51
 
53
52
  > **Breaking change in 0.5.0:** there is no longer a default signing key. Earlier versions silently fell back to a baked-in agent when no key was configured; that fallback has been removed. With no key configured, every command now fails closed with `No signing key found...`. Set a key explicitly (see [Configuration](#configuration)).
54
53
 
@@ -16,7 +16,7 @@ Built for agent use:
16
16
  - RedStone-signed solvency math handled internally, with a regression test pinning the half-boundary `toFixed(8)` encoding.
17
17
  - ParaSwap calldata validated client-side against the on-chain executor allowlist before broadcast.
18
18
 
19
- **Current version:** 0.5.1 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
19
+ **Current version:** 0.5.2 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
20
20
 
21
21
  > **Breaking change in 0.5.0:** there is no longer a default signing key. Earlier versions silently fell back to a baked-in agent when no key was configured; that fallback has been removed. With no key configured, every command now fails closed with `No signing key found...`. Set a key explicitly (see [Configuration](#configuration)).
22
22
 
@@ -57,6 +57,13 @@ prime-summary reports live solvency (health ratio, total value, debt, solvent fl
57
57
  SolvencyFacetProdArbitrum, read via eth_call with a RedStone price payload appended (falls
58
58
  back to balances-only if the gateway is unreachable).
59
59
 
60
+ NOTE: prime-summary shows TWO health metrics — don't confuse them:
61
+ - "Health ratio (chain)": on-chain getHealthRatio. 1.0 = liquidation, >1.0 = solvent.
62
+ - "Health (Bruno 0-100%)": equity-based, frontend-style. 0% = liquidation, 50% = half
63
+ borrowing power used, 100% = no debt.
64
+ Formula for the latter: equity=supplied-debt, max_debt=equity*(tier-1),
65
+ pct=(max_debt-debt)/max_debt*100. tier=5 (BASIC) or 10 (PREMIUM).
66
+
60
67
  Collateral withdrawal is a two-step, time-delayed flow on the Prime Account (there is NO
61
68
  instant withdraw of in-account collateral). The savings-pool `withdraw` above is a separate
62
69
  two-step intent flow on the pool itself, not the Prime Account. Step 1: `withdraw --pool X
@@ -1961,6 +1968,49 @@ def gather_lending(w3, account):
1961
1968
  r["usd"] = None
1962
1969
  return out
1963
1970
 
1971
+ def _compute_bruno_health(data: dict, tier_code: int = 0) -> dict:
1972
+ """Compute Bruno's 0-100% health from gather_lending data + tier.
1973
+
1974
+ DeltaPrime has *two* health metrics that agents must not confuse:
1975
+
1976
+ 1. health_ratio (on-chain, getHealthRatio): 1.0 = liquidation, >1.0 = solvent.
1977
+ This is the raw weighted-collateral / debt ratio from the SolvencyFacet.
1978
+
1979
+ 2. bruno_pct (equity-based, 0-100%): the scale used in the DeltaPrime frontend
1980
+ and the account-health-monitor cron. 0% = liquidation, 100% = no debt.
1981
+ Formula:
1982
+ equity = supplied_usd - debt_usd
1983
+ max_mult = 10 if PREMIUM tier else 5 if BASIC
1984
+ max_debt = equity * (max_mult - 1)
1985
+ bruno_pct = (max_debt - debt_usd) / max_debt * 100
1986
+
1987
+ Returns dict with keys: bruno_pct, supplied_usd, debt_usd, equity, max_debt,
1988
+ tier_label, or error.
1989
+ """
1990
+ supplied_usd = sum(r.get("usd", 0) or 0 for r in data.get("supplied", []))
1991
+ debt_usd = sum(r.get("usd", 0) or 0 for r in data.get("borrowed", []))
1992
+ equity = supplied_usd - debt_usd
1993
+ tier_labels = {0: "BASIC", 1: "PREMIUM", 2: "_NON_EXISTENT"}
1994
+ tier_label = tier_labels.get(tier_code, str(tier_code))
1995
+ max_mult = {0: 5, 1: 10}.get(tier_code, 5)
1996
+
1997
+ if equity <= 0.01:
1998
+ return {"bruno_pct": 0.0, "supplied_usd": round(supplied_usd, 2),
1999
+ "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2000
+ "max_debt": 0.0, "tier": tier_label, "error": "equity near zero"}
2001
+
2002
+ max_debt = equity * (max_mult - 1)
2003
+ if max_debt > 0 and debt_usd >= 0:
2004
+ bruno_pct = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
2005
+ bruno_pct = max(0.0, min(100.0, bruno_pct))
2006
+ else:
2007
+ bruno_pct = 100.0
2008
+
2009
+ return {"bruno_pct": round(bruno_pct, 1), "supplied_usd": round(supplied_usd, 2),
2010
+ "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2011
+ "max_debt": round(max_debt, 2), "tier": tier_label}
2012
+
2013
+
1964
2014
  def cmd_prime_summary():
1965
2015
  w3 = get_w3()
1966
2016
  acct = get_account()
@@ -2004,7 +2054,23 @@ def cmd_prime_summary():
2004
2054
  # gather_lending nulls the ratio when debt is negligible (the raw value is
2005
2055
  # astronomically large there); render that as ">1000" rather than a junk number.
2006
2056
  ratio_str = ">1000.00 (negligible debt)" if ratio is None else f"{ratio:.4f}"
2007
- print(f" Health ratio: {ratio_str} (>1.0 = solvent)")
2057
+ print(f" Health ratio (chain): {ratio_str} (>1.0 = solvent, 1.0 = liquidation)")
2058
+ # ─── Bruno's 0-100% health (equity-based, uses tier multiplier) ───
2059
+ # Different from health_ratio! See _compute_bruno_health docstring.
2060
+ # Get tier from the Prime Account (oracle-free view)
2061
+ try:
2062
+ tier_info = gather_prime_tier(w3, acct, account)
2063
+ tier_code = tier_info.get("tier_code", 0)
2064
+ except Exception:
2065
+ tier_code = 0
2066
+ bh = _compute_bruno_health(data, tier_code)
2067
+ if "error" not in bh:
2068
+ print(f" Health (Bruno 0-100%): {bh['bruno_pct']:.1f}%")
2069
+ print(f" (supplied=${bh['supplied_usd']:.2f}, debt=${bh['debt_usd']:.2f},"
2070
+ f" equity=${bh['equity']:.2f}, max_debt=${bh['max_debt']:.2f}, {bh['tier']})")
2071
+ print(f" 0%=liquidation 50%=half borrowing power used 100%=no debt")
2072
+ else:
2073
+ print(f" Health (Bruno 0-100%): N/A ({bh['error']})")
2008
2074
  print(f" Solvent: {'yes' if data['solvent'] else 'NO — liquidatable'}")
2009
2075
  else:
2010
2076
  print(f" Health/solvency: RedStone fetch/call failed ({data.get('solvency_error', 'error')}); "
@@ -5005,9 +5071,12 @@ def gather_defi() -> dict:
5005
5071
  result["total_usd"] = lending["total_value_usd"]
5006
5072
  result["health_ratio"] = lending["health_ratio"]
5007
5073
  result["solvent"] = lending["solvent"]
5074
+ # Compute Bruno's 0-100% health from lending data + tier
5075
+ result["bruno_pct"] = _compute_bruno_health(lending, tier.get("tier_code", 0)).get("bruno_pct")
5008
5076
  if lending["supplied"] or lending["borrowed"]:
5009
5077
  result["groups"].append({
5010
5078
  "type": "Lending / Leverage", "health_ratio": lending["health_ratio"],
5079
+ "bruno_pct": result["bruno_pct"],
5011
5080
  "supplied": [{"symbol": r["symbol"], "balance": r["balance"], "usd": r.get("usd")}
5012
5081
  for r in lending["supplied"]],
5013
5082
  "borrowed": [{"symbol": r["symbol"], "balance": r["balance"], "usd": r.get("usd")}
@@ -15,6 +15,7 @@ Usage:
15
15
  degenprime create-account [--execute]
16
16
  degenprime create-account --fund-pool usdc --fund-amount 100 [--execute]
17
17
  degenprime summary
18
+ degenprime defi --json (aggregate ALL positions as DeBank-style JSON; read-only)
18
19
  degenprime fund --pool usdc --amount 100 [--execute]
19
20
  degenprime borrow --pool usdc --amount 100 [--execute]
20
21
  degenprime repay --pool usdc --amount 100 [--execute]
@@ -1293,34 +1294,10 @@ def _prices_usd(w3, account, symbols: list, payload: bytes) -> dict:
1293
1294
  except Exception:
1294
1295
  return {}
1295
1296
 
1296
- def cmd_summary(as_json: bool = False):
1297
- """Read-only Degen Account view: in-account collateral, debts, and live
1298
- RedStone-gated solvency (getTotalValue/getDebt/getHealthRatio/isSolvent). Falls
1299
- back to balances-only if the RedStone gateway is unreachable or a view reverts.
1300
- Note: per-asset USD is best-effort - only symbols with a RedStone primary-prod
1301
- feed are priced here. Symbols sourced on-chain from BaseOracle TWAP show as
1302
- balance-only (the SolvencyFacet still values them for the total/debt figures).
1303
-
1304
- With --json: emits a single JSON object covering wallet, account, native
1305
- balance, per-asset supplied/borrowed with optional USD, poolDeposits (the EOA's
1306
- 'Diamond Hands' lending-pool balances, emitted even with no Degen Account),
1307
- total/debt/health-ratio/solvent flags. Null fields, empty lists, and empty dicts are dropped (same
1308
- trim contract as `deltaprime defi --json`). Numeric 0 and boolean false are
1309
- preserved.
1310
-
1311
- Multicall: stage A batches getAllOwnedAssets + getDebts (2 -> 1 RPC). Stage B
1312
- batches one getBalance per owned asset (N -> 1 RPC). Stage C batches the four
1313
- RedStone-gated solvency views + getPrices (4-5 -> 1 RPC), each leg carrying the
1314
- same RedStone payload appended."""
1315
- w3 = get_w3()
1316
- acct = get_account()
1317
- pa = get_prime_account(w3, acct.address)
1318
- if not as_json:
1319
- print(f"Wallet: {acct.address}")
1320
-
1321
- # Pool deposits ("Diamond Hands") are EOA balances independent of the Degen Account,
1322
- # so read them up front via one Multicall3 — they must surface even for a wallet with
1323
- # no Degen Account (e.g. a deposit made before creating one).
1297
+ def _gather_pool_deposits(w3, owner: str) -> list:
1298
+ """The EOA's 'Diamond Hands' lending-pool balances, read independently of the Degen
1299
+ Account via one Multicall3 (one balanceOf per pool). Surfaces even for a wallet with
1300
+ no Degen Account. Returns [{symbol, raw, decimals}, ...] for non-zero balances."""
1324
1301
  pool_deposits = []
1325
1302
  dep_legs, dep_meta = [], []
1326
1303
  for _pname, _pcfg in POOLS.items():
@@ -1329,7 +1306,7 @@ def cmd_summary(as_json: bool = False):
1329
1306
  except Exception:
1330
1307
  continue
1331
1308
  _proxy_cs = Web3.to_checksum_address(_pcfg["proxy"])
1332
- dep_legs.append((_proxy_cs, bytes.fromhex(_pc.encode_abi("balanceOf", args=[acct.address])[2:])))
1309
+ dep_legs.append((_proxy_cs, bytes.fromhex(_pc.encode_abi("balanceOf", args=[owner])[2:])))
1333
1310
  dep_meta.append(_pcfg)
1334
1311
  if dep_legs:
1335
1312
  try:
@@ -1340,32 +1317,21 @@ def cmd_summary(as_json: bool = False):
1340
1317
  _bal = w3.codec.decode(["uint256"], _rd)[0] if _ok and _rd else 0
1341
1318
  if _bal > 0:
1342
1319
  pool_deposits.append({"symbol": _pcfg["symbol"], "raw": _bal, "decimals": _pcfg["decimals"]})
1320
+ return pool_deposits
1343
1321
 
1344
- if not pa:
1345
- # No Degen Account: still surface Diamond Hands deposits (balance-only — the
1346
- # RedStone getPrices view lives on the Degen Account, absent here).
1347
- if as_json:
1348
- out = {"wallet": acct.address, "account": None}
1349
- if pool_deposits:
1350
- out["poolDeposits"] = [{"symbol": r["symbol"], "amount": r["raw"] / 10**r["decimals"]}
1351
- for r in pool_deposits]
1352
- print(json.dumps(out, indent=2))
1353
- else:
1354
- print("No Degen Account yet. Create one with: degenprime create-account --execute")
1355
- if pool_deposits:
1356
- print(" Pool Deposits (Diamond Hands):")
1357
- for r in pool_deposits:
1358
- print(f" {r['symbol']:<8} {r['raw'] / 10**r['decimals']:,.6f}")
1359
- return
1360
1322
 
1361
- pa_eth = w3.eth.get_balance(pa) / 1e18
1362
- if not as_json:
1363
- print(f"Degen Account: {pa}")
1364
- if pa_eth >= 1e-9:
1365
- print(f" Native ETH (gas): {pa_eth:.6f}")
1323
+ def _gather_account_state(w3, account, pool_deposits: list):
1324
+ """Read-only collateral / debt / RedStone-gated solvency for an existing Degen Account.
1325
+ Shared by `summary` and `defi`. Returns (pa_eth, supplied, borrowed, solvency) where
1326
+ supplied/borrowed are [{symbol, raw, decimals}, ...] and solvency carries
1327
+ total/debt/ratio/solvent/error/prices. pool_deposits is taken so their feeds get folded
1328
+ into the RedStone payload (else getPrices reverts on a deposit-only symbol).
1366
1329
 
1367
- account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
1330
+ Multicall: stage A batches getAllOwnedAssets + getDebts (2 -> 1 RPC). Stage B batches
1331
+ one getBalance per owned asset (N -> 1 RPC). Stage C batches the four RedStone-gated
1332
+ solvency views + getPrices (4-5 -> 1 RPC), each leg carrying the same payload appended."""
1368
1333
  pa_cs = account.address
1334
+ pa_eth = w3.eth.get_balance(pa_cs) / 1e18
1369
1335
  stage_a_legs = [
1370
1336
  ("getAllOwnedAssets", ["bytes32[]"], account.encode_abi("getAllOwnedAssets", args=[])),
1371
1337
  ("getDebts", ["(bytes32,uint256)[]"], account.encode_abi("getDebts", args=[])),
@@ -1449,6 +1415,160 @@ def cmd_summary(as_json: bool = False):
1449
1415
  solvency["prices"] = prices
1450
1416
  except Exception as e:
1451
1417
  solvency["error"] = type(e).__name__
1418
+ return pa_eth, supplied, borrowed, solvency
1419
+
1420
+
1421
+ def gather_defi() -> dict:
1422
+ """Aggregate ALL DegenPrime positions for the selected wallet into one DeBank-style dict,
1423
+ matching the cross-tool shape `deltaprime defi --json` emits. Read-only: reuses the same
1424
+ gather helpers as `summary` (lending/solvency via the RedStone-gated views, plus the EOA's
1425
+ own pool deposits surfaced as a Savings group). Empty groups are omitted. total_usd /
1426
+ health_ratio / solvent come from the RedStone-gated solvency views; per-asset USD is
1427
+ best-effort (omitted where a RedStone feed is missing). Never broadcasts."""
1428
+ w3 = get_w3()
1429
+ acct = get_account()
1430
+ pa = get_prime_account(w3, acct.address)
1431
+ result = {
1432
+ "protocol": "DegenPrime", "url": "https://degenprime.io", "chain": "base",
1433
+ "wallet": acct.address, "prime_account": pa,
1434
+ "total_usd": None, "health_ratio": None, "solvent": None,
1435
+ "groups": [], "status": "ok",
1436
+ }
1437
+
1438
+ pool_deposits = _gather_pool_deposits(w3, acct.address)
1439
+ # _gather_account_state folds pool-deposit symbols into getPrices, so this map covers
1440
+ # both in-account assets and Diamond-Hands deposits. Empty with no Degen Account.
1441
+ prices = {}
1442
+
1443
+ if pa:
1444
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
1445
+ _pa_eth, supplied, borrowed, solvency = _gather_account_state(w3, account, pool_deposits)
1446
+ prices = solvency["prices"]
1447
+ result["total_usd"] = solvency["total"]
1448
+ result["health_ratio"] = solvency["ratio"]
1449
+ result["solvent"] = solvency["solvent"]
1450
+ if solvency["error"]:
1451
+ result["solvency_error"] = solvency["error"]
1452
+
1453
+ def _row(r):
1454
+ amt = r["raw"] / 10**r["decimals"]
1455
+ row = {"symbol": r["symbol"], "balance": f"{amt:.6f}"}
1456
+ usd = prices.get(r["symbol"])
1457
+ if usd is not None:
1458
+ row["usd"] = round(amt * usd, 2)
1459
+ return row
1460
+
1461
+ if supplied or borrowed:
1462
+ result["groups"].append({
1463
+ "type": "Lending / Leverage", "health_ratio": solvency["ratio"],
1464
+ "supplied": [_row(r) for r in supplied],
1465
+ "borrowed": [_row(r) for r in borrowed],
1466
+ })
1467
+
1468
+ # Savings: the EOA's own pool deposits ("Diamond Hands"), independent of the Degen
1469
+ # Account (so NOT in getTotalValue) — surfaced as their own group and added on top.
1470
+ # Priced from the same RedStone read used for the account (no extra RPC).
1471
+ if pool_deposits:
1472
+ sav_rows, sav_usd_total = [], 0.0
1473
+ for r in pool_deposits:
1474
+ amt = r["raw"] / 10**r["decimals"]
1475
+ row = {"symbol": r["symbol"], "balance": f"{amt:.6f}"}
1476
+ usd = prices.get(r["symbol"])
1477
+ if usd is not None:
1478
+ row["usd"] = round(amt * usd, 2)
1479
+ sav_usd_total += amt * usd
1480
+ sav_rows.append(row)
1481
+ result["groups"].append({"type": "Savings", "label": "Savings", "supplied": sav_rows})
1482
+ if sav_usd_total:
1483
+ result["total_usd"] = (result["total_usd"] or 0) + sav_usd_total
1484
+
1485
+ return result
1486
+
1487
+
1488
+ _DEFI_DECORATIVE_KEYS = {"url"}
1489
+
1490
+
1491
+ def _trim_defi_json(value):
1492
+ """Recursively strip noise from `defi --json` output so an LLM consumer doesn't pay
1493
+ context for fields that carry no information: drops dict keys whose value is exactly
1494
+ None, drops keys whose value is an empty list or empty dict, drops the decorative
1495
+ top-level `url` key, but PRESERVES numeric 0 and boolean False (zero balance,
1496
+ explicitly-not-solvent, etc.) and keeps the top-level structure so a consumer can tell
1497
+ what's missing from what shape the response took. Same contract as `deltaprime`'s."""
1498
+ if isinstance(value, dict):
1499
+ out = {}
1500
+ for k, v in value.items():
1501
+ if k in _DEFI_DECORATIVE_KEYS:
1502
+ continue
1503
+ trimmed = _trim_defi_json(v)
1504
+ if trimmed is None:
1505
+ continue
1506
+ if isinstance(trimmed, (list, dict)) and len(trimmed) == 0:
1507
+ continue
1508
+ out[k] = trimmed
1509
+ return out
1510
+ if isinstance(value, list):
1511
+ return [_trim_defi_json(v) for v in value]
1512
+ return value
1513
+
1514
+
1515
+ def cmd_defi(as_json: bool = True):
1516
+ """Aggregate all DegenPrime positions for the wallet. Default output is the DeBank-style
1517
+ JSON (the cross-tool shape the health monitor consumes). On error, emits
1518
+ {"status":"error", ...} rather than raising, so the caller always gets parseable JSON."""
1519
+ try:
1520
+ data = gather_defi()
1521
+ except Exception as e:
1522
+ data = {"protocol": "DegenPrime", "chain": "base",
1523
+ "status": "error", "error": f"{type(e).__name__}: {e}"}
1524
+ print(json.dumps(_trim_defi_json(data), indent=2))
1525
+
1526
+
1527
+ def cmd_summary(as_json: bool = False):
1528
+ """Read-only Degen Account view: in-account collateral, debts, and live
1529
+ RedStone-gated solvency (getTotalValue/getDebt/getHealthRatio/isSolvent). Falls
1530
+ back to balances-only if the RedStone gateway is unreachable or a view reverts.
1531
+ Note: per-asset USD is best-effort - only symbols with a RedStone primary-prod
1532
+ feed are priced here. Symbols sourced on-chain from BaseOracle TWAP show as
1533
+ balance-only (the SolvencyFacet still values them for the total/debt figures).
1534
+
1535
+ With --json: emits a single JSON object covering wallet, account, native
1536
+ balance, per-asset supplied/borrowed with optional USD, poolDeposits (the EOA's
1537
+ 'Diamond Hands' lending-pool balances, emitted even with no Degen Account),
1538
+ total/debt/health-ratio/solvent flags. Null fields, empty lists, and empty dicts are dropped (same
1539
+ trim contract as `deltaprime defi --json`). Numeric 0 and boolean false are
1540
+ preserved."""
1541
+ w3 = get_w3()
1542
+ acct = get_account()
1543
+ pa = get_prime_account(w3, acct.address)
1544
+ if not as_json:
1545
+ print(f"Wallet: {acct.address}")
1546
+
1547
+ pool_deposits = _gather_pool_deposits(w3, acct.address)
1548
+
1549
+ if not pa:
1550
+ # No Degen Account: still surface Diamond Hands deposits (balance-only — the
1551
+ # RedStone getPrices view lives on the Degen Account, absent here).
1552
+ if as_json:
1553
+ out = {"wallet": acct.address, "account": None}
1554
+ if pool_deposits:
1555
+ out["poolDeposits"] = [{"symbol": r["symbol"], "amount": r["raw"] / 10**r["decimals"]}
1556
+ for r in pool_deposits]
1557
+ print(json.dumps(out, indent=2))
1558
+ else:
1559
+ print("No Degen Account yet. Create one with: degenprime create-account --execute")
1560
+ if pool_deposits:
1561
+ print(" Pool Deposits (Diamond Hands):")
1562
+ for r in pool_deposits:
1563
+ print(f" {r['symbol']:<8} {r['raw'] / 10**r['decimals']:,.6f}")
1564
+ return
1565
+
1566
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
1567
+ pa_eth, supplied, borrowed, solvency = _gather_account_state(w3, account, pool_deposits)
1568
+ if not as_json:
1569
+ print(f"Degen Account: {pa}")
1570
+ if pa_eth >= 1e-9:
1571
+ print(f" Native ETH (gas): {pa_eth:.6f}")
1452
1572
 
1453
1573
  if as_json:
1454
1574
  def _asset_row(r):
@@ -2315,6 +2435,8 @@ def _dispatch():
2315
2435
  cmd_create_account("--execute" in args, fund_pool, fund_amount)
2316
2436
  elif cmd == "summary":
2317
2437
  cmd_summary(as_json="--json" in args)
2438
+ elif cmd == "defi":
2439
+ cmd_defi("--json" in args)
2318
2440
  elif cmd == "fund":
2319
2441
  pool, amount = None, None
2320
2442
  execute = "--execute" in args
@@ -50,6 +50,13 @@ prime-summary reports live solvency (health ratio, total value, debt, solvent fl
50
50
  SolvencyFacetProdAvalanche, read via eth_call with a RedStone price payload appended (falls
51
51
  back to balances-only if the gateway is unreachable).
52
52
 
53
+ NOTE: prime-summary shows TWO health metrics — don't confuse them:
54
+ - "Health ratio (chain)": on-chain getHealthRatio. 1.0 = liquidation, >1.0 = solvent.
55
+ - "Health (Bruno 0-100%)": equity-based, frontend-style. 0% = liquidation, 50% = half
56
+ borrowing power used, 100% = no debt.
57
+ Formula for the latter: equity=supplied-debt, max_debt=equity*(tier-1),
58
+ pct=(max_debt-debt)/max_debt*100. tier=5 (BASIC) or 10 (PREMIUM).
59
+
53
60
  Collateral withdrawal is a two-step, time-delayed flow on the Prime Account (there is NO
54
61
  instant withdraw of in-account collateral). The savings-pool `withdraw` above is a separate
55
62
  two-step intent flow on the pool itself, not the Prime Account. Step 1: `withdraw --pool X
@@ -658,33 +665,42 @@ def _tx_gas_price(w3) -> int:
658
665
 
659
666
  def _set_gas_price(w3, tx_dict):
660
667
  """Set appropriate gas price fields for the chain, replacing the legacy gasPrice approach.
661
- On EIP-1559 chains (Arbitrum, Base): sets maxFeePerGas + maxPriorityFeePerGas with a 2x
662
- base-fee hedge (base + prio + 1 gwei buffer). On Avalanche (legacy): sets gasPrice at
663
- 2x base fee with a 1 gwei floor. (25 gwei was the pre-Etna C-chain minimum;
664
- ACP-125 (Dec 2024) lowered the min base fee to 1 nAVAX — base now sits at ~0.01
665
- nAVAX, so a 25 gwei floor overpaid ~2500x and inflated the upfront balance
666
- requirement past small EOAs.)"""
668
+ On EIP-1559 chains (Arbitrum, Base, Avalanche post-Etna): sets maxFeePerGas +
669
+ maxPriorityFeePerGas with a 2x base-fee hedge (base + prio + 1 gwei buffer).
670
+ Falls back to legacy gasPrice only if the tx dict already lacks EIP-1559 fields
671
+ and the chain doesn't support max_priority_fee.
672
+ (25 gwei was the pre-Etna C-chain minimum; ACP-125 (Dec 2024) lowered the min base
673
+ fee to 1 nAVAX — base now sits at ~0.01 nAVAX, so a 25 gwei floor overpaid ~2500x
674
+ and inflated the upfront balance requirement past small EOAs.)"""
675
+ # If build_transaction already set EIP-1559 fields, don't touch them
676
+ if "maxFeePerGas" in tx_dict or "maxPriorityFeePerGas" in tx_dict:
677
+ tx_dict.pop("gasPrice", None)
678
+ return
667
679
  tx_dict.pop("gasPrice", None)
668
- if CHAIN_ID in (42161, 8453): # Arbitrum, Base — EIP-1559
680
+ try:
669
681
  base = w3.eth.gas_price
670
682
  prio = w3.eth.max_priority_fee
671
683
  tx_dict["maxFeePerGas"] = max(int(base * 2), base + prio + 10**9)
672
684
  tx_dict["maxPriorityFeePerGas"] = prio
673
- else: # Avalanche (43114) — legacy gasPrice
685
+ except Exception:
686
+ # Legacy chain — use gasPrice instead
674
687
  tx_dict["gasPrice"] = max(int(w3.eth.gas_price * 2), 1 * 10**9)
675
688
 
676
689
  def _set_gas_price_for(chain_id, w3, tx_dict):
677
690
  """Set gas fields for an EXPLICIT chain_id rather than the module CHAIN_ID. Needed by
678
691
  cross-chain flows (prime-bridge) where a tx may target Avalanche or Arbitrum regardless
679
- of which tool built it. Arbitrum/Base (EIP-1559): maxFeePerGas + maxPriorityFeePerGas;
680
- Avalanche (legacy): gasPrice with a 1 gwei floor (post-Etna; see _set_gas_price)."""
692
+ of which tool built it."""
693
+ # If build_transaction already set EIP-1559 fields, don't touch them
694
+ if "maxFeePerGas" in tx_dict or "maxPriorityFeePerGas" in tx_dict:
695
+ tx_dict.pop("gasPrice", None)
696
+ return
681
697
  tx_dict.pop("gasPrice", None)
682
- if chain_id in (42161, 8453): # Arbitrum, Base — EIP-1559
698
+ try:
683
699
  base = w3.eth.gas_price
684
700
  prio = w3.eth.max_priority_fee
685
701
  tx_dict["maxFeePerGas"] = max(int(base * 2), base + prio + 10**9)
686
702
  tx_dict["maxPriorityFeePerGas"] = prio
687
- else: # Avalanche (43114) — legacy gasPrice
703
+ except Exception:
688
704
  tx_dict["gasPrice"] = max(int(w3.eth.gas_price * 2), 1 * 10**9)
689
705
 
690
706
  def _read_env_var(path, var):
@@ -1979,6 +1995,49 @@ def gather_lending(w3, account):
1979
1995
  r["usd"] = None
1980
1996
  return out
1981
1997
 
1998
+ def _compute_bruno_health(data: dict, tier_code: int = 0) -> dict:
1999
+ """Compute Bruno's 0-100% health from gather_lending data + tier.
2000
+
2001
+ DeltaPrime has *two* health metrics that agents must not confuse:
2002
+
2003
+ 1. health_ratio (on-chain, getHealthRatio): 1.0 = liquidation, >1.0 = solvent.
2004
+ This is the raw weighted-collateral / debt ratio from the SolvencyFacet.
2005
+
2006
+ 2. bruno_pct (equity-based, 0-100%): the scale used in the DeltaPrime frontend
2007
+ and the account-health-monitor cron. 0% = liquidation, 100% = no debt.
2008
+ Formula:
2009
+ equity = supplied_usd - debt_usd
2010
+ max_mult = 10 if PREMIUM tier else 5 if BASIC
2011
+ max_debt = equity * (max_mult - 1)
2012
+ bruno_pct = (max_debt - debt_usd) / max_debt * 100
2013
+
2014
+ Returns dict with keys: bruno_pct, supplied_usd, debt_usd, equity, max_debt,
2015
+ tier_label, or error.
2016
+ """
2017
+ supplied_usd = sum(r.get("usd", 0) or 0 for r in data.get("supplied", []))
2018
+ debt_usd = sum(r.get("usd", 0) or 0 for r in data.get("borrowed", []))
2019
+ equity = supplied_usd - debt_usd
2020
+ tier_labels = {0: "BASIC", 1: "PREMIUM", 2: "_NON_EXISTENT"}
2021
+ tier_label = tier_labels.get(tier_code, str(tier_code))
2022
+ max_mult = {0: 5, 1: 10}.get(tier_code, 5)
2023
+
2024
+ if equity <= 0.01:
2025
+ return {"bruno_pct": 0.0, "supplied_usd": round(supplied_usd, 2),
2026
+ "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2027
+ "max_debt": 0.0, "tier": tier_label, "error": "equity near zero"}
2028
+
2029
+ max_debt = equity * (max_mult - 1)
2030
+ if max_debt > 0 and debt_usd >= 0:
2031
+ bruno_pct = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
2032
+ bruno_pct = max(0.0, min(100.0, bruno_pct))
2033
+ else:
2034
+ bruno_pct = 100.0
2035
+
2036
+ return {"bruno_pct": round(bruno_pct, 1), "supplied_usd": round(supplied_usd, 2),
2037
+ "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2038
+ "max_debt": round(max_debt, 2), "tier": tier_label}
2039
+
2040
+
1982
2041
  def cmd_prime_summary():
1983
2042
  w3 = get_w3()
1984
2043
  acct = get_account()
@@ -2022,7 +2081,23 @@ def cmd_prime_summary():
2022
2081
  # gather_lending nulls the ratio when debt is negligible (the raw value is
2023
2082
  # astronomically large there); render that as ">1000" rather than a junk number.
2024
2083
  ratio_str = ">1000.00 (negligible debt)" if ratio is None else f"{ratio:.4f}"
2025
- print(f" Health ratio: {ratio_str} (>1.0 = solvent)")
2084
+ print(f" Health ratio (chain): {ratio_str} (>1.0 = solvent, 1.0 = liquidation)")
2085
+ # ─── Bruno's 0-100% health (equity-based, uses tier multiplier) ───
2086
+ # Different from health_ratio! See _compute_bruno_health docstring.
2087
+ # Get tier from the Prime Account (oracle-free view)
2088
+ try:
2089
+ tier_info = gather_prime_tier(w3, acct, account)
2090
+ tier_code = tier_info.get("tier_code", 0)
2091
+ except Exception:
2092
+ tier_code = 0
2093
+ bh = _compute_bruno_health(data, tier_code)
2094
+ if "error" not in bh:
2095
+ print(f" Health (Bruno 0-100%): {bh['bruno_pct']:.1f}%")
2096
+ print(f" (supplied=${bh['supplied_usd']:.2f}, debt=${bh['debt_usd']:.2f},"
2097
+ f" equity=${bh['equity']:.2f}, max_debt=${bh['max_debt']:.2f}, {bh['tier']})")
2098
+ print(f" 0%=liquidation 50%=half borrowing power used 100%=no debt")
2099
+ else:
2100
+ print(f" Health (Bruno 0-100%): N/A ({bh['error']})")
2026
2101
  print(f" Solvent: {'yes' if data['solvent'] else 'NO — liquidatable'}")
2027
2102
  else:
2028
2103
  print(f" Health/solvency: RedStone fetch/call failed ({data.get('solvency_error', 'error')}); "
@@ -4928,9 +5003,12 @@ def gather_defi() -> dict:
4928
5003
  result["total_usd"] = lending["total_value_usd"]
4929
5004
  result["health_ratio"] = lending["health_ratio"]
4930
5005
  result["solvent"] = lending["solvent"]
5006
+ # Compute Bruno's 0-100% health from lending data + tier
5007
+ result["bruno_pct"] = _compute_bruno_health(lending, tier.get("tier_code", 0)).get("bruno_pct")
4931
5008
  if lending["supplied"] or lending["borrowed"]:
4932
5009
  result["groups"].append({
4933
5010
  "type": "Lending / Leverage", "health_ratio": lending["health_ratio"],
5011
+ "bruno_pct": result["bruno_pct"],
4934
5012
  "supplied": [{"symbol": r["symbol"], "balance": r["balance"], "usd": r.get("usd")}
4935
5013
  for r in lending["supplied"]],
4936
5014
  "borrowed": [{"symbol": r["symbol"], "balance": r["balance"], "usd": r.get("usd")}
@@ -39,11 +39,19 @@ TIER_MAX = {"basic": 5, "premium": 10}
39
39
  def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
40
40
  """Compute Bruno's 0-100% health scale from defi --json data.
41
41
 
42
+ PREFERS the precomputed ``bruno_pct`` from defi --json (which primecli now
43
+ includes as of 2026-06-04), falling back to manual calculation when absent.
44
+
42
45
  Formula:
43
46
  equity = total_supplied_usd - total_debt_usd
44
47
  max_debt = equity * (tier - 1) # PREMIUM=10, BASIC=5
45
48
  health% = (max_debt - debt) / max_debt * 100
46
49
 
50
+ This is DIFFERENT from getHealthRatio (the on-chain ratio where 1.0 = liquidation).
51
+ Do NOT convert between the two. The ``health_ratio`` metric is the on-chain value
52
+ (1.0=liquidation, >1.0=solvent). The ``bruno_pct`` is the equity-based frontend
53
+ measurement (0%=liquidation, 50%=half borrowing power used, 100%=no debt).
54
+
47
55
  Returns dict with health metrics or error.
48
56
  """
49
57
  # Parse groups (DeltaPrime format) or flat format (DegenPrime)
@@ -53,6 +61,31 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
53
61
  supplied = g.get("supplied", [])
54
62
  borrowed = g.get("borrowed", [])
55
63
  health_ratio = g.get("health_ratio", 0) or 0
64
+ # Use precomputed bruno_pct from defi --json if available (primecli >= 0.5.0)
65
+ precomputed = g.get("bruno_pct")
66
+ if precomputed is not None:
67
+ # Early return: precomputed value exists, enrich with detail fields
68
+ supplied_usd = sum(s.get("usd", 0) or 0 for s in supplied)
69
+ debt_usd = sum(b.get("usd", 0) or 0 for b in borrowed)
70
+ equity = supplied_usd - debt_usd
71
+ raw_usdc = sum(s.get("usd", 0) for s in supplied if s.get("symbol") == "USDC")
72
+ symbols = [s.get("symbol", "") for s in supplied]
73
+ has_gmx = any("GM_" in sym for sym in symbols)
74
+ has_lb = any(sym in ("LB_AVAX_USDC", "LB_WAVAX_USDC", "JOE") or "TRADERJOE" in sym.upper() for sym in symbols)
75
+ has_aero = any("AERO" in sym.upper() or "CL_POSITION" in sym.upper() for sym in symbols)
76
+ return {
77
+ "bruno_pct": float(precomputed),
78
+ "health_ratio": round(health_ratio, 4),
79
+ "supplied_usd": round(supplied_usd, 2),
80
+ "debt_usd": round(debt_usd, 2),
81
+ "equity": round(equity, 2),
82
+ "max_debt": round(max(0, equity * (max_mult - 1)), 2),
83
+ "raw_usdc": round(raw_usdc, 2),
84
+ "has_gmx": has_gmx,
85
+ "has_lb": has_lb,
86
+ "has_aero": has_aero,
87
+ "action": "computed from defi --json bruno_pct",
88
+ }
56
89
  else:
57
90
  supplied = defi_data.get("supplied", [])
58
91
  borrowed = defi_data.get("borrowed", [])
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
5
5
  Author: Mnemosyne-quest contributors
6
6
  License: MIT
@@ -28,7 +28,6 @@ Requires-Dist: eth-account>=0.13
28
28
  Requires-Dist: eth-keys>=0.5
29
29
  Requires-Dist: eth-abi<7,>=5.0
30
30
  Requires-Dist: requests>=2.31
31
- Dynamic: license-file
32
31
 
33
32
  # primecli
34
33
 
@@ -48,7 +47,7 @@ Built for agent use:
48
47
  - RedStone-signed solvency math handled internally, with a regression test pinning the half-boundary `toFixed(8)` encoding.
49
48
  - ParaSwap calldata validated client-side against the on-chain executor allowlist before broadcast.
50
49
 
51
- **Current version:** 0.5.1 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.5.2 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
52
51
 
53
52
  > **Breaking change in 0.5.0:** there is no longer a default signing key. Earlier versions silently fell back to a baked-in agent when no key was configured; that fallback has been removed. With no key configured, every command now fails closed with `No signing key found...`. Set a key explicitly (see [Configuration](#configuration)).
54
53
 
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68", "wheel"]
2
+ requires = ["setuptools>=68,<73", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "primecli"
7
- version = "0.5.1"
7
+ version = "0.5.3"
8
8
  description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes