primecli 0.6.1__tar.gz → 0.7.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.
Files changed (23) hide show
  1. {primecli-0.6.1 → primecli-0.7.1}/PKG-INFO +2 -2
  2. {primecli-0.6.1 → primecli-0.7.1}/README.md +1 -1
  3. {primecli-0.6.1 → primecli-0.7.1}/primecli/arbprime.py +1 -1
  4. {primecli-0.6.1 → primecli-0.7.1}/primecli/degenprime.py +26 -14
  5. {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/PKG-INFO +2 -2
  6. {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/SOURCES.txt +1 -0
  7. {primecli-0.6.1 → primecli-0.7.1}/pyproject.toml +1 -1
  8. {primecli-0.6.1 → primecli-0.7.1}/tests/test_cross_file_identity.py +5 -0
  9. primecli-0.7.1/tests/test_health_meter.py +340 -0
  10. {primecli-0.6.1 → primecli-0.7.1}/tests/test_health_monitor.py +13 -10
  11. {primecli-0.6.1 → primecli-0.7.1}/LICENSE +0 -0
  12. {primecli-0.6.1 → primecli-0.7.1}/primecli/__init__.py +0 -0
  13. {primecli-0.6.1 → primecli-0.7.1}/primecli/deltaprime.py +0 -0
  14. {primecli-0.6.1 → primecli-0.7.1}/primecli/health_monitor.py +0 -0
  15. {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/dependency_links.txt +0 -0
  16. {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/entry_points.txt +0 -0
  17. {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/requires.txt +0 -0
  18. {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/top_level.txt +0 -0
  19. {primecli-0.6.1 → primecli-0.7.1}/setup.cfg +0 -0
  20. {primecli-0.6.1 → primecli-0.7.1}/tests/test_gas_pricing.py +0 -0
  21. {primecli-0.6.1 → primecli-0.7.1}/tests/test_paraswap_validator.py +0 -0
  22. {primecli-0.6.1 → primecli-0.7.1}/tests/test_redstone_encoding.py +0 -0
  23. {primecli-0.6.1 → primecli-0.7.1}/tests/test_to_wei_units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.6.1
3
+ Version: 0.7.1
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
@@ -47,7 +47,7 @@ Built for agent use:
47
47
  - RedStone-signed solvency math handled internally, with a regression test pinning the half-boundary `toFixed(8)` encoding.
48
48
  - ParaSwap calldata validated client-side against the on-chain executor allowlist before broadcast.
49
49
 
50
- **Current version:** 0.5.6 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.7.0 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
51
51
 
52
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)).
53
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.6 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.7.0 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
 
@@ -503,7 +503,7 @@ _T_WEETH = {"addr": "0x35751007a407ca6FEFfE80b3cB397736D2cf4dbe", "symbol": "we
503
503
  TJ_LB_PAIRS = {
504
504
  "eth-usdc": {"pair": "0x69f1216cB2905bf0852f74624D5Fa7b5FC4dA710", "router": TJ_ROUTER_V21, "binStep": 15, "tokenX": _T_WETH, "tokenY": _T_USDC},
505
505
  "eth-usdc-10": {"pair": "0xb7236B927e03542AC3bE0A054F2bEa8868AF9508", "router": TJ_ROUTER_V22, "binStep": 10, "tokenX": _T_WETH, "tokenY": _T_USDC},
506
- "eth-usdt": {"pair": "0xd387c40a72703B38A5181573724bcaF2Ce6038a5", "router": TJ_ROUTER_V21, "binStep": 15, "tokenX": _T_WETH, "tokenY": _T_USDC},
506
+ "eth-usdt": {"pair": "0xd387c40a72703B38A5181573724bcaF2Ce6038a5", "router": TJ_ROUTER_V21, "binStep": 15, "tokenX": _T_WETH, "tokenY": _T_USDT},
507
507
  }
508
508
 
509
509
  # LB pair (ILBPair) reads used for previews + position views. getActiveId is the current
@@ -126,7 +126,6 @@ except ImportError:
126
126
  BASE_RPC = os.environ.get("DEGENPRIME_RPC", "https://base.publicnode.com")
127
127
  EXPLORER = "https://basescan.org"
128
128
  CHAIN_ID = 8453
129
- DEGEN_MAX_MULT = 5 # Fixed max leverage multiplier (no tier system on DegenPrime)
130
129
  ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
131
130
 
132
131
  # ── Signing key resolution ──────────────────────────────────────────────────
@@ -1115,7 +1114,9 @@ def build_redstone_payload(symbols: list) -> bytes:
1115
1114
  # Metadata = rest of timestamp + version + data service ID + null terminator
1116
1115
  signed_metadata = f"{ts_str[1:]}#0.9.0#{REDSTONE_DATA_SERVICE}\0".encode()
1117
1116
  payload += signed_metadata
1118
- payload += len(signed_metadata).to_bytes(2, "big")
1117
+ # Unsigned metadata size = padding(3) + ts_digit(1) + signed_metadata
1118
+ unsigned_meta_size = len(signed_metadata) + 4
1119
+ payload += unsigned_meta_size.to_bytes(3, "big")
1119
1120
  payload += REDSTONE_MARKER
1120
1121
  return payload
1121
1122
 
@@ -1809,6 +1810,14 @@ def _gather_account_state(w3, account, pool_deposits: list):
1809
1810
  sym = n.rstrip(b"\x00").decode(errors="replace")
1810
1811
  if v > 0:
1811
1812
  borrowed.append({"symbol": sym, "raw": v, "decimals": _asset_decimals(w3, sym)})
1813
+ # Per-asset on-chain debtCoverage (Base: un-tiered) stamped onto every row so the
1814
+ # frontend-exact getHealthMeter computation has its dc inputs.
1815
+ try:
1816
+ dc_map = _resolve_debt_coverages(w3, [r["symbol"] for r in supplied + borrowed])
1817
+ for r in supplied + borrowed:
1818
+ r["dc"] = dc_map.get(r["symbol"], 0.0)
1819
+ except Exception:
1820
+ pass
1812
1821
 
1813
1822
  # Solvency views (SolvencyFacet) are RedStone-gated: they revert (0xe7764c9e)
1814
1823
  # without signed price calldata appended. Fetch a fresh RedStone payload covering
@@ -1916,7 +1925,7 @@ def gather_defi() -> dict:
1916
1925
  _rows_borrowed = [_row(r) for r in borrowed]
1917
1926
 
1918
1927
  # Compute equity-based health (0-100%)
1919
- _hp = _compute_health_pct(_rows_supplied, _rows_borrowed)
1928
+ _hp = _compute_health_pct(_rows_supplied, _rows_borrowed, w3=w3)
1920
1929
  result["health_pct"] = _hp.get("health_pct")
1921
1930
 
1922
1931
  if supplied or borrowed:
@@ -2043,7 +2052,7 @@ def cmd_summary(as_json: bool = False):
2043
2052
  # Compute equity-based health (0-100%)
2044
2053
  _hp_rows = [_asset_row(r) for r in supplied]
2045
2054
  _hp_borrowed = [_asset_row(r) for r in borrowed]
2046
- _hp = _compute_health_pct(_hp_rows, _hp_borrowed)
2055
+ _hp = _compute_health_pct(_hp_rows, _hp_borrowed, w3=w3)
2047
2056
 
2048
2057
  out = {
2049
2058
  "wallet": acct.address,
@@ -2091,17 +2100,24 @@ def cmd_summary(as_json: bool = False):
2091
2100
  print(f" {r['symbol']:<8} {r['raw'] / 10**r['decimals']:,.6f}{usd_str}")
2092
2101
 
2093
2102
  if solvency["error"] is None:
2094
- print(f" Total value: ${solvency['total']:,.2f}")
2095
- print(f" Debt: ${solvency['debt']:,.2f}")
2103
+ # A solvency view can come back None even with no error (e.g. a multicall leg that
2104
+ # decoded empty); guard the currency format so summary never crashes on it.
2105
+ tv_str = f"${solvency['total']:,.2f}" if solvency["total"] is not None else "n/a"
2106
+ debt_str = f"${solvency['debt']:,.2f}" if solvency["debt"] is not None else "n/a"
2107
+ print(f" Total value: {tv_str}")
2108
+ print(f" Debt: {debt_str}")
2096
2109
  ratio_str = ">1000.00 (negligible debt)" if solvency["ratio"] is None else f"{solvency['ratio']:.4f}"
2097
2110
  print(f" Health ratio (chain): {ratio_str} (>1.0 = solvent, 1.0 = liquidation)")
2098
2111
  # ─── Equity-based health (0-100%) ───
2099
2112
  # Different from health_ratio!
2100
2113
  hp = _compute_health_pct(
2101
- [{"symbol": r["symbol"], "balance": "0", "usd": (r["raw"] / 10**r["decimals"]) * solvency["prices"].get(r["symbol"], 0)}
2114
+ [{"symbol": r["symbol"], "balance": "0", "dc": r.get("dc", 0.0),
2115
+ "usd": (r["raw"] / 10**r["decimals"]) * solvency["prices"].get(r["symbol"], 0)}
2102
2116
  for r in supplied if solvency["prices"].get(r["symbol"])],
2103
- [{"symbol": r["symbol"], "balance": "0", "usd": (r["raw"] / 10**r["decimals"]) * solvency["prices"].get(r["symbol"], 0)}
2117
+ [{"symbol": r["symbol"], "balance": "0", "dc": r.get("dc", 0.0),
2118
+ "usd": (r["raw"] / 10**r["decimals"]) * solvency["prices"].get(r["symbol"], 0)}
2104
2119
  for r in borrowed if solvency["prices"].get(r["symbol"])],
2120
+ w3=w3,
2105
2121
  )
2106
2122
  if "error" not in hp:
2107
2123
  print(f" Health (0-100%): {hp['health_pct']:.1f}%")
@@ -2136,9 +2152,7 @@ def cmd_borrow(pool_name: str, amount: float, execute: bool = False):
2136
2152
  pa_cs = Web3.to_checksum_address(pa)
2137
2153
  account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2138
2154
  # borrow has remainsSolvent -> needs RedStone price payload appended to calldata.
2139
- feeds = degen_account_price_feeds(account)
2140
- if symbol in REDSTONE_AVAILABLE_FEEDS and symbol not in feeds:
2141
- feeds.append(symbol)
2155
+ feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
2142
2156
  payload = build_redstone_payload(feeds)
2143
2157
  base_calldata = account.encode_abi("borrow", args=[asset_b32(symbol), amount_wei])
2144
2158
  data = base_calldata + payload.hex()
@@ -2869,9 +2883,7 @@ def cmd_execute_withdrawal(pool_name: str, index: int = None, execute: bool = Fa
2869
2883
  print("Run with --execute to broadcast (pulls the funds to the wallet).")
2870
2884
  return
2871
2885
 
2872
- feeds = degen_account_price_feeds(account)
2873
- if symbol in REDSTONE_AVAILABLE_FEEDS and symbol not in feeds:
2874
- feeds.append(symbol)
2886
+ feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
2875
2887
  payload = build_redstone_payload(feeds)
2876
2888
  base_calldata = account.encode_abi("executeWithdrawalIntent", args=[asset_b32(symbol), ready])
2877
2889
  data = base_calldata + payload.hex()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.6.1
3
+ Version: 0.7.1
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
@@ -47,7 +47,7 @@ Built for agent use:
47
47
  - RedStone-signed solvency math handled internally, with a regression test pinning the half-boundary `toFixed(8)` encoding.
48
48
  - ParaSwap calldata validated client-side against the on-chain executor allowlist before broadcast.
49
49
 
50
- **Current version:** 0.5.6 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.7.0 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
51
51
 
52
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)).
53
53
 
@@ -14,6 +14,7 @@ primecli.egg-info/requires.txt
14
14
  primecli.egg-info/top_level.txt
15
15
  tests/test_cross_file_identity.py
16
16
  tests/test_gas_pricing.py
17
+ tests/test_health_meter.py
17
18
  tests/test_health_monitor.py
18
19
  tests/test_paraswap_validator.py
19
20
  tests/test_redstone_encoding.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "primecli"
7
- version = "0.6.1"
7
+ version = "0.7.1"
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"
@@ -43,6 +43,11 @@ SHARED_FUNCS = [
43
43
  "_redstone_package_signer",
44
44
  "build_redstone_payload",
45
45
  "to_wei_units",
46
+ # Frontend-exact getHealthMeter core + its on-chain debtCoverage resolver. The math
47
+ # and the TokenManager wiring must not drift — a wrong dc or formula misreports every
48
+ # account's health identically wrongly on whichever chain drifted.
49
+ "_health_meter_pct",
50
+ "_resolve_debt_coverages",
46
51
  ]
47
52
 
48
53
 
@@ -0,0 +1,340 @@
1
+ """Tests for the frontend-exact getHealthMeter health computation.
2
+
3
+ `_health_meter_pct` is the shared, byte-identical core across deltaprime / arbprime /
4
+ degenprime (the cross-file identity test pins that parity). It is a pure function over a
5
+ per-asset list of {symbol, dc, supplied_usd, borrowed_usd}, so these tests are fully
6
+ offline. `_compute_health_pct` is exercised with `_resolve_debt_coverages` monkeypatched
7
+ so no chain is ever touched (the dc read is the only network seam).
8
+
9
+ The reference formula (HealthMeterFacetProd.getHealthMeter):
10
+ net_i = supplied_usd_i - borrowed_usd_i
11
+ weightedCollateral = Σ dc_i·net_i (net-long legs) - Σ dc_i·(-net_i) (net-short legs)
12
+ weightedBorrowed = Σ dc_i·borrowed_usd_i
13
+ borrowed = Σ borrowed_usd_i (UNWEIGHTED)
14
+ borrowed == 0 -> 100
15
+ wc > 0 and wc + weightedBorrowed > borrowed
16
+ -> (wc + weightedBorrowed - borrowed) / wc · 100 (clamped 0..100)
17
+ else -> 0
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import random
24
+
25
+ import pytest
26
+
27
+ DELTA = importlib.import_module("primecli.deltaprime")
28
+ ARB = importlib.import_module("primecli.arbprime")
29
+ DEGEN = importlib.import_module("primecli.degenprime")
30
+
31
+ DC_MAJOR = 10.0 / 11.0 # 0.909090909… (10x class)
32
+ DC_RISKY = 5.0 / 6.0 # 0.833333333… (5x class)
33
+
34
+
35
+ def _leveraged(dc: float, equity: float, debt: float) -> list:
36
+ """Model a leveraged-farm position: one collateral asset holding (equity+debt) long,
37
+ one debt asset borrowed `debt`. equity = supplied - borrowed across the two legs."""
38
+ return [
39
+ {"symbol": "COLL", "dc": dc, "supplied_usd": equity + debt, "borrowed_usd": 0.0},
40
+ {"symbol": "DEBT", "dc": dc, "supplied_usd": 0.0, "borrowed_usd": debt},
41
+ ]
42
+
43
+
44
+ # ──────────────────────────────────────────────────────────────────────────────
45
+ # Reference anchors from the brief (queried on-chain): dc 0.909091 and 0.833333
46
+
47
+
48
+ def test_uniform_major_dc_zero_half_full():
49
+ """dc = 0.909091 (10x class), equity = 100: D=0 → 100%, D=500 → 50%, D=1000 → 0%."""
50
+ f = DELTA._health_meter_pct
51
+ assert f(_leveraged(DC_MAJOR, 100, 0))["health_pct"] == 100.0
52
+ assert f(_leveraged(DC_MAJOR, 100, 500))["health_pct"] == 50.0
53
+ assert f(_leveraged(DC_MAJOR, 100, 1000))["health_pct"] == 0.0
54
+
55
+
56
+ def test_uniform_risky_dc_max_debt_500():
57
+ """dc = 0.833333 (5x class), equity = 100: health hits exactly 0 at D = 500 (the 5x
58
+ max-debt line). Just under is barely positive, at/over is 0."""
59
+ f = DELTA._health_meter_pct
60
+ assert f(_leveraged(DC_RISKY, 100, 499))["health_pct"] > 0.0
61
+ assert f(_leveraged(DC_RISKY, 100, 500))["health_pct"] == 0.0
62
+ assert f(_leveraged(DC_RISKY, 100, 501))["health_pct"] == 0.0
63
+
64
+
65
+ def test_no_debt_is_full_health():
66
+ f = DELTA._health_meter_pct
67
+ assert f([{"symbol": "A", "dc": DC_MAJOR, "supplied_usd": 1234.5, "borrowed_usd": 0.0}])["health_pct"] == 100.0
68
+ # No assets at all (empty account) → no debt → 100.
69
+ assert f([])["health_pct"] == 100.0
70
+
71
+
72
+ def test_net_short_leg_subtracts_from_collateral():
73
+ """An asset borrowed MORE than its free balance is a net-short leg: it subtracts a
74
+ dc-weighted amount from weightedCollateral (it does not count as collateral)."""
75
+ f = DELTA._health_meter_pct
76
+ # COLL long 1000; X borrowed 600 with only 100 free balance → net short 500 on X.
77
+ assets = [
78
+ {"symbol": "COLL", "dc": DC_MAJOR, "supplied_usd": 1000.0, "borrowed_usd": 0.0},
79
+ {"symbol": "X", "dc": DC_MAJOR, "supplied_usd": 100.0, "borrowed_usd": 600.0},
80
+ ]
81
+ wc_plus = DC_MAJOR * 1000.0
82
+ wc_minus = DC_MAJOR * 500.0
83
+ wc = wc_plus - wc_minus
84
+ wb = DC_MAJOR * 600.0
85
+ borrowed = 600.0
86
+ expected = max(0.0, min(100.0, (wc + wb - borrowed) / wc * 100.0))
87
+ assert f(assets)["health_pct"] == pytest.approx(round(expected, 1), abs=0.05)
88
+
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────────
91
+ # Mixed-dc: sweep vs an independent inline re-implementation of the contract formula
92
+
93
+
94
+ def _reference_health(assets: list) -> float:
95
+ """Independent re-implementation of getHealthMeter — deliberately NOT sharing code
96
+ with _health_meter_pct, so the sweep cross-checks the real implementation."""
97
+ wcp = wcm = wb = bor = 0.0
98
+ for a in assets:
99
+ dc = a["dc"]
100
+ net = a["supplied_usd"] - a["borrowed_usd"]
101
+ if net > 0:
102
+ wcp += dc * net
103
+ elif net < 0:
104
+ wcm += dc * (-net)
105
+ wb += dc * a["borrowed_usd"]
106
+ bor += a["borrowed_usd"]
107
+ wc = wcp - wcm
108
+ if bor <= 0:
109
+ return 100.0
110
+ if wc > 0 and wc + wb > bor:
111
+ return max(0.0, min(100.0, (wc + wb - bor) / wc * 100.0))
112
+ return 0.0
113
+
114
+
115
+ def test_mixed_dc_sweep_matches_reference():
116
+ f = DELTA._health_meter_pct
117
+ rng = random.Random(20260606)
118
+ dcs = [DC_MAJOR, DC_RISKY, 0.9, 0.8, 0.75, 0.5]
119
+ for _ in range(20000):
120
+ n = rng.randint(1, 5)
121
+ assets = [
122
+ {
123
+ "symbol": f"S{i}",
124
+ "dc": rng.choice(dcs),
125
+ "supplied_usd": rng.uniform(0, 5000),
126
+ "borrowed_usd": rng.uniform(0, 5000),
127
+ }
128
+ for i in range(n)
129
+ ]
130
+ got = f(assets)["health_pct"]
131
+ exp = round(_reference_health(assets), 1)
132
+ # Both round to one decimal; the only gap is half-ULP rounding, well under 0.05.
133
+ assert abs(got - exp) <= 0.05, (assets, got, exp)
134
+
135
+
136
+ def test_core_is_identical_object_across_siblings():
137
+ """Defensive: the three siblings expose the same callable behaviour (the cross-file
138
+ identity test pins the source; this pins runtime behaviour on a shared input)."""
139
+ assets = _leveraged(DC_MAJOR, 250, 1000)
140
+ r_delta = DELTA._health_meter_pct(assets)
141
+ r_arb = ARB._health_meter_pct(assets)
142
+ r_degen = DEGEN._health_meter_pct(assets)
143
+ assert r_delta == r_arb == r_degen
144
+
145
+
146
+ # ──────────────────────────────────────────────────────────────────────────────
147
+ # _compute_health_pct — dc resolution mocked (no network), tier label + max_debt
148
+
149
+
150
+ def test_compute_health_pct_delta_mocks_dc(monkeypatch):
151
+ """deltaprime._compute_health_pct resolves dc via _resolve_debt_coverages (mocked here),
152
+ merges per-symbol supplied/borrowed, and reports the contract health plus a display
153
+ max_debt and the tier label. PREMIUM (tier 1) → dc 0.909091 → 50% at the half-debt line."""
154
+ monkeypatch.setattr(
155
+ DELTA, "_resolve_debt_coverages",
156
+ lambda w3, syms, tier_code=0: {s: DC_MAJOR for s in syms},
157
+ )
158
+ data = {
159
+ "w3": object(), # never used — dc is mocked
160
+ "supplied": [{"symbol": "USDC", "usd": 600.0}],
161
+ "borrowed": [{"symbol": "AVAX", "usd": 500.0}],
162
+ }
163
+ hp = DELTA._compute_health_pct(data, tier_code=1)
164
+ assert hp["equity"] == 100.0
165
+ assert hp["health_pct"] == 50.0
166
+ assert hp["tier"] == "PREMIUM"
167
+ assert hp["max_debt"] == pytest.approx(1000.0, abs=0.5) # equity·dc/(1-dc) = 100·10 = 1000
168
+
169
+
170
+ def test_compute_health_pct_delta_uses_row_dc_without_w3(monkeypatch):
171
+ """When rows already carry `dc` (stamped by gather_lending), no resolver call is needed
172
+ even with w3 absent."""
173
+ called = {"n": 0}
174
+
175
+ def _boom(*a, **k):
176
+ called["n"] += 1
177
+ raise AssertionError("resolver must not be called when rows carry dc")
178
+
179
+ monkeypatch.setattr(DELTA, "_resolve_debt_coverages", _boom)
180
+ data = {
181
+ "supplied": [{"symbol": "USDC", "usd": 1500.0, "dc": DC_RISKY}],
182
+ "borrowed": [{"symbol": "BTC", "usd": 500.0, "dc": DC_RISKY}],
183
+ }
184
+ hp = DELTA._compute_health_pct(data, tier_code=0)
185
+ assert called["n"] == 0
186
+ assert hp["equity"] == 1000.0
187
+ # equity 1000, dc 0.833333 → max_debt 5000 → debt 500 → health 90%.
188
+ assert hp["health_pct"] == 90.0
189
+ assert hp["tier"] == "BASIC"
190
+
191
+
192
+ def test_compute_health_pct_degen_list_signature(monkeypatch):
193
+ """degenprime._compute_health_pct takes (supplied, borrowed) lists and resolves dc with
194
+ the un-tiered Base coverage (mocked). Equity near zero → error branch."""
195
+ monkeypatch.setattr(
196
+ DEGEN, "_resolve_debt_coverages",
197
+ lambda w3, syms, tier_code=0: {s: DC_MAJOR for s in syms},
198
+ )
199
+ supplied = [{"symbol": "USDC", "usd": 1000.0}]
200
+ borrowed = [{"symbol": "USDC", "usd": 1000.0}]
201
+ hp = DEGEN._compute_health_pct(supplied, borrowed, w3=object())
202
+ assert hp["error"] == "equity near zero"
203
+ assert hp["health_pct"] == 0.0
204
+
205
+
206
+ def test_compute_health_pct_equity_near_zero_delta(monkeypatch):
207
+ monkeypatch.setattr(
208
+ DELTA, "_resolve_debt_coverages",
209
+ lambda w3, syms, tier_code=0: {s: DC_MAJOR for s in syms},
210
+ )
211
+ data = {
212
+ "w3": object(),
213
+ "supplied": [{"symbol": "USDC", "usd": 1000.0}],
214
+ "borrowed": [{"symbol": "USDC", "usd": 1000.0}],
215
+ }
216
+ hp = DELTA._compute_health_pct(data, tier_code=0)
217
+ assert hp["error"] == "equity near zero"
218
+ assert hp["health_pct"] == 0.0
219
+ assert hp["max_debt"] == 0.0
220
+
221
+
222
+ # ──────────────────────────────────────────────────────────────────────────────
223
+ # _resolve_debt_coverages — the on-chain dc read wiring, multicall mocked
224
+
225
+
226
+ class _FakeCodec:
227
+ def encode(self, types, values):
228
+ from eth_abi import encode as _enc
229
+ return _enc(types, values)
230
+
231
+ def decode(self, types, data):
232
+ from eth_abi import decode as _dec
233
+ return _dec(types, data)
234
+
235
+
236
+ class _FakeW3:
237
+ """Just enough of a w3 for _resolve_debt_coverages: a codec and a contract whose
238
+ encode_abi produces real selectors+args. multicall is monkeypatched, so no RPC."""
239
+
240
+ def __init__(self):
241
+ self.codec = _FakeCodec()
242
+ self.eth = self
243
+
244
+ def contract(self, address=None, abi=None):
245
+ import web3
246
+ return web3.Web3().eth.contract(address=address, abi=abi)
247
+
248
+
249
+ def _enc_addr(addr):
250
+ from eth_abi import encode
251
+ from web3 import Web3
252
+ return encode(["address"], [Web3.to_checksum_address(addr)])
253
+
254
+
255
+ def _enc_dc(dc):
256
+ from eth_abi import encode
257
+ return encode(["uint256"], [int(round(dc * 1e18))])
258
+
259
+
260
+ def _patch_dc_cache(mod, monkeypatch):
261
+ """Give the module a fresh dc cache so cases don't bleed into each other."""
262
+ monkeypatch.setattr(mod, "_dc_cache", {})
263
+
264
+
265
+ def test_resolve_dc_tiered_success_avalanche(monkeypatch):
266
+ """Avalanche/Arbitrum path: getAssetAddress resolves, tieredDebtCoverage(tier) returns a
267
+ nonzero value, so the un-tiered fallback is never consulted."""
268
+ _patch_dc_cache(DELTA, monkeypatch)
269
+ addr = "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed"
270
+ calls = {"batches": 0}
271
+
272
+ def fake_multicall(w3, legs):
273
+ calls["batches"] += 1
274
+ if calls["batches"] == 1: # getAssetAddress batch
275
+ return [(True, _enc_addr(addr)) for _ in legs]
276
+ if calls["batches"] == 2: # tieredDebtCoverage batch — succeeds
277
+ return [(True, _enc_dc(DC_MAJOR)) for _ in legs]
278
+ # untiered batch — should not be needed, but return something harmless
279
+ return [(True, _enc_dc(DC_RISKY)) for _ in legs]
280
+
281
+ monkeypatch.setattr(DELTA, "multicall", fake_multicall)
282
+ out = DELTA._resolve_debt_coverages(_FakeW3(), ["USDC", "ETH"], tier_code=1)
283
+ assert out == {"USDC": pytest.approx(DC_MAJOR), "ETH": pytest.approx(DC_MAJOR)}
284
+
285
+
286
+ def test_resolve_dc_tiered_reverts_falls_back_untiered(monkeypatch):
287
+ """Base path: tieredDebtCoverage reverts (success=False), so the resolver falls back to
288
+ the un-tiered debtCoverage value per asset."""
289
+ _patch_dc_cache(DEGEN, monkeypatch)
290
+ addr = "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed"
291
+ calls = {"batches": 0}
292
+
293
+ def fake_multicall(w3, legs):
294
+ calls["batches"] += 1
295
+ if calls["batches"] == 1: # getAssetAddress
296
+ return [(True, _enc_addr(addr)) for _ in legs]
297
+ if calls["batches"] == 2: # tieredDebtCoverage — reverts on Base
298
+ return [(False, b"") for _ in legs]
299
+ return [(True, _enc_dc(DC_RISKY)) for _ in legs] # untiered debtCoverage
300
+
301
+ monkeypatch.setattr(DEGEN, "multicall", fake_multicall)
302
+ out = DEGEN._resolve_debt_coverages(_FakeW3(), ["DEGEN"], tier_code=0)
303
+ assert out == {"DEGEN": pytest.approx(DC_RISKY)}
304
+ assert calls["batches"] == 3 # addr + tiered(revert) + untiered
305
+
306
+
307
+ def test_resolve_dc_unresolvable_symbol_is_zero(monkeypatch):
308
+ """A symbol whose getAssetAddress returns the zero address (not listed) gets dc=0 — it
309
+ then contributes nothing to the health meter, matching the contract skipping it."""
310
+ _patch_dc_cache(DELTA, monkeypatch)
311
+
312
+ def fake_multicall(w3, legs):
313
+ # getAssetAddress returns zero address for the lone symbol; no further batches.
314
+ return [(True, _enc_addr("0x0000000000000000000000000000000000000000")) for _ in legs]
315
+
316
+ monkeypatch.setattr(DELTA, "multicall", fake_multicall)
317
+ out = DELTA._resolve_debt_coverages(_FakeW3(), ["MYSTERY"], tier_code=0)
318
+ assert out == {"MYSTERY": 0.0}
319
+
320
+
321
+ def test_resolve_dc_is_cached(monkeypatch):
322
+ """Second lookup of the same (symbol, tier) is served from cache — multicall not re-hit."""
323
+ _patch_dc_cache(DELTA, monkeypatch)
324
+ addr = "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed"
325
+ calls = {"n": 0}
326
+
327
+ def fake_multicall(w3, legs):
328
+ calls["n"] += 1
329
+ b = calls["n"]
330
+ if b == 1:
331
+ return [(True, _enc_addr(addr)) for _ in legs]
332
+ return [(True, _enc_dc(DC_MAJOR)) for _ in legs]
333
+
334
+ monkeypatch.setattr(DELTA, "multicall", fake_multicall)
335
+ w3 = _FakeW3()
336
+ first = DELTA._resolve_debt_coverages(w3, ["USDC"], tier_code=1)
337
+ n_after_first = calls["n"]
338
+ second = DELTA._resolve_debt_coverages(w3, ["USDC"], tier_code=1)
339
+ assert first == second
340
+ assert calls["n"] == n_after_first # no new batches on the cached call
@@ -7,12 +7,15 @@ subprocess.run, so we monkeypatch that single seam to feed canned `defi --json`
7
7
  and `prime-tier` output and to record any borrow/repay/gmx-deposit invocation —
8
8
  no real process is ever spawned, no chain is touched.
9
9
 
10
- Health pct formula (with tier multiplier `max_mult`):
10
+ Health pct formula (uniform-power fallback, with tier multiplier `max_mult`):
11
11
  equity = sum(supplied.usd) - sum(borrowed.usd)
12
- max_debt = equity * (max_mult - 1)
13
- pct = (max_debt - min(debt, max_debt)) / max_debt * 100
14
- For premium (max_mult=10) with equity=2000 max_debt=18000, the debt levels
15
- below land on 85% (lever), 50% (in range) and 10% (de-lever) for a 30-70 range.
12
+ max_debt = max_mult * equity
13
+ pct = 100 * (1 - debt / max_debt)
14
+ This matches the on-chain getHealthMeter zero-crossing (debt == max_mult·equity
15
+ health 0), where max_mult = dc/(1-dc) is the asset's borrowing power (10 for the
16
+ 0.909091 class, 5 for 0.833333). For premium (max_mult=10) with equity=2000 →
17
+ max_debt=20000, the debt levels below land on 86.5% (lever), 55% (in range) and
18
+ 19% (de-lever) for a 30-70 range.
16
19
  """
17
20
 
18
21
  from __future__ import annotations
@@ -57,7 +60,7 @@ def test_compute_health_lever_branch_high_pct():
57
60
  max_mult=10,
58
61
  )
59
62
  assert h["equity"] == 2000
60
- assert h["health_pct"] == 85.0
63
+ assert h["health_pct"] == 86.5
61
64
  assert h["health_pct"] > 70 # lever territory
62
65
 
63
66
 
@@ -71,7 +74,7 @@ def test_compute_health_in_range_branch():
71
74
  max_mult=10,
72
75
  )
73
76
  assert h["equity"] == 2000
74
- assert h["health_pct"] == 50.0
77
+ assert h["health_pct"] == 55.0
75
78
  assert 30 <= h["health_pct"] <= 70
76
79
 
77
80
 
@@ -85,7 +88,7 @@ def test_compute_health_delever_branch_low_pct():
85
88
  max_mult=10,
86
89
  )
87
90
  assert h["equity"] == 2000
88
- assert h["health_pct"] == 10.0
91
+ assert h["health_pct"] == 19.0
89
92
  assert h["health_pct"] < 30 # de-lever territory
90
93
 
91
94
 
@@ -110,8 +113,8 @@ def test_compute_health_basic_tier_lower_ceiling():
110
113
  basic = hm.compute_health(_grouped(supplied, borrowed), max_mult=5)
111
114
  premium = hm.compute_health(_grouped(supplied, borrowed), max_mult=10)
112
115
  assert basic["equity"] == premium["equity"] == 2000
113
- assert basic["max_debt"] == 2000 * 4
114
- assert premium["max_debt"] == 2000 * 9
116
+ assert basic["max_debt"] == 2000 * 5
117
+ assert premium["max_debt"] == 2000 * 10
115
118
  assert basic["health_pct"] < premium["health_pct"]
116
119
 
117
120
 
File without changes
File without changes
File without changes