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.
- {primecli-0.6.1 → primecli-0.7.1}/PKG-INFO +2 -2
- {primecli-0.6.1 → primecli-0.7.1}/README.md +1 -1
- {primecli-0.6.1 → primecli-0.7.1}/primecli/arbprime.py +1 -1
- {primecli-0.6.1 → primecli-0.7.1}/primecli/degenprime.py +26 -14
- {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/SOURCES.txt +1 -0
- {primecli-0.6.1 → primecli-0.7.1}/pyproject.toml +1 -1
- {primecli-0.6.1 → primecli-0.7.1}/tests/test_cross_file_identity.py +5 -0
- primecli-0.7.1/tests/test_health_meter.py +340 -0
- {primecli-0.6.1 → primecli-0.7.1}/tests/test_health_monitor.py +13 -10
- {primecli-0.6.1 → primecli-0.7.1}/LICENSE +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli/__init__.py +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli/deltaprime.py +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli/health_monitor.py +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/setup.cfg +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/tests/test_gas_pricing.py +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.6.1 → primecli-0.7.1}/tests/test_redstone_encoding.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
2095
|
-
|
|
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", "
|
|
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", "
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
13
|
-
pct = (
|
|
14
|
-
|
|
15
|
-
|
|
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"] ==
|
|
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"] ==
|
|
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"] ==
|
|
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 *
|
|
114
|
-
assert premium["max_debt"] == 2000 *
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|