primecli 0.5.5__tar.gz → 0.5.7__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.5.5 → primecli-0.5.7}/PKG-INFO +2 -2
- {primecli-0.5.5 → primecli-0.5.7}/README.md +1 -1
- {primecli-0.5.5 → primecli-0.5.7}/primecli/arbprime.py +104 -38
- {primecli-0.5.5 → primecli-0.5.7}/primecli/degenprime.py +135 -28
- {primecli-0.5.5 → primecli-0.5.7}/primecli/deltaprime.py +83 -26
- {primecli-0.5.5 → primecli-0.5.7}/primecli/health_monitor.py +125 -65
- {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.5.5 → primecli-0.5.7}/pyproject.toml +1 -1
- {primecli-0.5.5 → primecli-0.5.7}/tests/test_gas_pricing.py +46 -15
- {primecli-0.5.5 → primecli-0.5.7}/tests/test_health_monitor.py +8 -8
- {primecli-0.5.5 → primecli-0.5.7}/LICENSE +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/primecli/__init__.py +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/SOURCES.txt +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/setup.cfg +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/tests/test_redstone_encoding.py +0 -0
- {primecli-0.5.5 → primecli-0.5.7}/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.5.
|
|
3
|
+
Version: 0.5.7
|
|
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.
|
|
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).
|
|
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.
|
|
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).
|
|
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
|
|
|
@@ -294,16 +294,16 @@ PARASWAP_AUGUSTUS = "0x6A000F20005980200259B80c5102003040001068"
|
|
|
294
294
|
PARASWAP_SUPPORTED_SELECTORS = {"0xe3ead59e", "0x876a02f6"}
|
|
295
295
|
# Executors the facet whitelists (ParaSwapHelper._checkExecutorAddress). Lowercased.
|
|
296
296
|
PARASWAP_EXECUTORS = {
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
#
|
|
297
|
+
# Historical static whitelist. Since the protocol-level facet fix (confirmed
|
|
298
|
+
# 2026-06-04), API-returned executors outside this set can be VALID — Velora
|
|
299
|
+
# rotates executors per quote (seen: 0x8faa…e820, 0x6f05…0900). The swap paths
|
|
300
|
+
# now decide by eth_call simulation of the exact tx, not by this set; it's kept
|
|
301
|
+
# only to label "known" vs "new" executors in output.
|
|
301
302
|
"0xdef171fe48cf0115b1d80b88dc8eab59176fee57",
|
|
302
303
|
"0x6a000f20005980200259b80c5102003040001068",
|
|
303
304
|
"0x000010036c0190e009a000d0fc3541100a07380a",
|
|
304
305
|
"0x00c600b30fb0400701010f4b080409018b9006e0",
|
|
305
306
|
"0xa0f408a000017007015e0f00320e470d00090a5b",
|
|
306
|
-
# 0x8faa... (Velora) returned by API but NOT whitelisted on-chain — confirmed 2026-05-28 & 2026-05-29
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
# RedStone on-demand oracle config for DeltaPrime on Arbitrum — IDENTICAL to Avalanche
|
|
@@ -671,33 +671,42 @@ def _tx_gas_price(w3) -> int:
|
|
|
671
671
|
|
|
672
672
|
def _set_gas_price(w3, tx_dict):
|
|
673
673
|
"""Set appropriate gas price fields for the chain, replacing the legacy gasPrice approach.
|
|
674
|
-
On EIP-1559 chains (Arbitrum, Base): sets maxFeePerGas +
|
|
675
|
-
base-fee hedge (base + prio + 1 gwei buffer).
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
674
|
+
On EIP-1559 chains (Arbitrum, Base, Avalanche post-Etna): sets maxFeePerGas +
|
|
675
|
+
maxPriorityFeePerGas with a 2x base-fee hedge (base + prio + 1 gwei buffer).
|
|
676
|
+
Falls back to legacy gasPrice only if the tx dict already lacks EIP-1559 fields
|
|
677
|
+
and the chain doesn't support max_priority_fee.
|
|
678
|
+
(25 gwei was the pre-Etna C-chain minimum; ACP-125 (Dec 2024) lowered the min base
|
|
679
|
+
fee to 1 nAVAX — base now sits at ~0.01 nAVAX, so a 25 gwei floor overpaid ~2500x
|
|
680
|
+
and inflated the upfront balance requirement past small EOAs.)"""
|
|
681
|
+
# If build_transaction already set EIP-1559 fields, don't touch them
|
|
682
|
+
if "maxFeePerGas" in tx_dict or "maxPriorityFeePerGas" in tx_dict:
|
|
683
|
+
tx_dict.pop("gasPrice", None)
|
|
684
|
+
return
|
|
680
685
|
tx_dict.pop("gasPrice", None)
|
|
681
|
-
|
|
686
|
+
try:
|
|
682
687
|
base = w3.eth.gas_price
|
|
683
688
|
prio = w3.eth.max_priority_fee
|
|
684
689
|
tx_dict["maxFeePerGas"] = max(int(base * 2), base + prio + 10**9)
|
|
685
690
|
tx_dict["maxPriorityFeePerGas"] = prio
|
|
686
|
-
|
|
691
|
+
except Exception:
|
|
692
|
+
# Legacy chain — use gasPrice instead
|
|
687
693
|
tx_dict["gasPrice"] = max(int(w3.eth.gas_price * 2), 1 * 10**9)
|
|
688
694
|
|
|
689
695
|
def _set_gas_price_for(chain_id, w3, tx_dict):
|
|
690
696
|
"""Set gas fields for an EXPLICIT chain_id rather than the module CHAIN_ID. Needed by
|
|
691
697
|
cross-chain flows (prime-bridge) where a tx may target Avalanche or Arbitrum regardless
|
|
692
|
-
of which tool built it.
|
|
693
|
-
|
|
698
|
+
of which tool built it."""
|
|
699
|
+
# If build_transaction already set EIP-1559 fields, don't touch them
|
|
700
|
+
if "maxFeePerGas" in tx_dict or "maxPriorityFeePerGas" in tx_dict:
|
|
701
|
+
tx_dict.pop("gasPrice", None)
|
|
702
|
+
return
|
|
694
703
|
tx_dict.pop("gasPrice", None)
|
|
695
|
-
|
|
704
|
+
try:
|
|
696
705
|
base = w3.eth.gas_price
|
|
697
706
|
prio = w3.eth.max_priority_fee
|
|
698
707
|
tx_dict["maxFeePerGas"] = max(int(base * 2), base + prio + 10**9)
|
|
699
708
|
tx_dict["maxPriorityFeePerGas"] = prio
|
|
700
|
-
|
|
709
|
+
except Exception:
|
|
701
710
|
tx_dict["gasPrice"] = max(int(w3.eth.gas_price * 2), 1 * 10**9)
|
|
702
711
|
|
|
703
712
|
def _read_env_var(path, var):
|
|
@@ -2326,14 +2335,40 @@ def _swap_via_paraswap(w3, acct, pa_cs, account, from_sym, to_sym, from_cfg, to_
|
|
|
2326
2335
|
selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
|
|
2327
2336
|
_exec, _src, _dest, from_amt, min_out = _paraswap_decode_and_check(
|
|
2328
2337
|
selector_hex, data_bytes, from_cfg["token"], to_cfg["token"], amount_in, pa_cs)
|
|
2329
|
-
# Same
|
|
2338
|
+
# Same simulate-first executor handling as swap-debt (see cmd_swap_debt for full
|
|
2339
|
+
# rationale): keep the API executor when the exact tx simulates clean; only fall
|
|
2340
|
+
# back to the legacy executor if the unpatched calldata reverts.
|
|
2330
2341
|
_PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2342
|
+
feeds = prime_account_price_feeds(account)
|
|
2343
|
+
for s in (from_sym, to_sym):
|
|
2344
|
+
if s not in feeds:
|
|
2345
|
+
feeds.append(s)
|
|
2346
|
+
payload = build_redstone_payload(feeds)
|
|
2347
|
+
def _sim_paraswap(db):
|
|
2348
|
+
base = account.encode_abi("paraSwapV6", args=[full[:4], db])
|
|
2349
|
+
try:
|
|
2350
|
+
w3.eth.call({"from": acct.address, "to": pa_cs,
|
|
2351
|
+
"data": base + payload.hex(), "gas": 8000000})
|
|
2352
|
+
return True, None
|
|
2353
|
+
except Exception as e:
|
|
2354
|
+
return False, str(e)
|
|
2355
|
+
sim_ok, sim_err = _sim_paraswap(data_bytes)
|
|
2356
|
+
if sim_ok:
|
|
2357
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
2358
|
+
print(f" ✓ Executor {_exec} not in the static whitelist, but the full tx "
|
|
2359
|
+
f"simulates clean — using the API calldata as-is.")
|
|
2360
|
+
else:
|
|
2361
|
+
print(f" ✗ Simulation with API executor {_exec} reverted: {sim_err}")
|
|
2362
|
+
patched = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:]) + data_bytes[32:]
|
|
2363
|
+
sim_ok, err2 = _sim_paraswap(patched)
|
|
2364
|
+
if sim_ok:
|
|
2365
|
+
print(f" ⚠ Falling back to legacy executor {_PARASWAP_FALLBACK_EXECUTOR} "
|
|
2366
|
+
f"(simulates clean).")
|
|
2367
|
+
data_bytes = patched
|
|
2368
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"],
|
|
2369
|
+
to_cfg["token"], amount_in, pa_cs)
|
|
2370
|
+
else:
|
|
2371
|
+
print(f" ✗ Legacy-executor fallback also reverted: {err2}")
|
|
2337
2372
|
|
|
2338
2373
|
print(f"Swap {amount} {from_sym} -> {to_sym} on Prime Account {pa_cs} (via ParaSwap/Velora)")
|
|
2339
2374
|
print(f" Router method: {price_route['contractMethod']} ({selector_hex})")
|
|
@@ -2348,10 +2383,12 @@ def _swap_via_paraswap(w3, acct, pa_cs, account, from_sym, to_sym, from_cfg, to_
|
|
|
2348
2383
|
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
2349
2384
|
return
|
|
2350
2385
|
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2386
|
+
if not sim_ok:
|
|
2387
|
+
print("✗ Refusing to broadcast: simulation reverted for both executor variants.")
|
|
2388
|
+
return
|
|
2389
|
+
|
|
2390
|
+
# Rebuild the payload fresh for broadcast (the sim payload may be near the
|
|
2391
|
+
# RedStone staleness window by now).
|
|
2355
2392
|
payload = build_redstone_payload(feeds)
|
|
2356
2393
|
base_calldata = account.encode_abi("paraSwapV6", args=[full[:4], data_bytes])
|
|
2357
2394
|
data = base_calldata + payload.hex()
|
|
@@ -2544,7 +2581,8 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2544
2581
|
|
|
2545
2582
|
Default (one-tx): SwapDebtFacet.swapDebtParaSwap — borrows _borrowAmount of _toAsset,
|
|
2546
2583
|
ParaSwaps it into _fromAsset, and repays _repayAmount of _fromAsset debt in a single tx.
|
|
2547
|
-
|
|
2584
|
+
(Was broken on-chain 2026-05-30 by a protocol-level Velora/ParaSwap facet bug; the
|
|
2585
|
+
DeltaPrime team fixed it — re-verified working via eth_call + live tx 2026-06-04.)
|
|
2548
2586
|
|
|
2549
2587
|
--fallback (manual 3-tx via YieldYak):
|
|
2550
2588
|
1. borrow to_sym — borrow the new debt asset into the account
|
|
@@ -2766,16 +2804,40 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2766
2804
|
_exec, _src, _dest, swap_from_amt, swap_min_out = _paraswap_decode_and_check(
|
|
2767
2805
|
selector_hex, data_bytes, to_cfg["token"], from_cfg["token"], borrow_amount, pa_cs)
|
|
2768
2806
|
|
|
2769
|
-
#
|
|
2770
|
-
#
|
|
2771
|
-
#
|
|
2807
|
+
# Velora/ParaSwap executors rotate per quote and the facet's on-chain executor
|
|
2808
|
+
# check was fixed at the protocol level (DeltaPrime team, confirmed by eth_call
|
|
2809
|
+
# 2026-06-04) — API-built calldata now passes with its own executor, while the old
|
|
2810
|
+
# hard-patch to the legacy executor REVERTS (executor-specific calldata mismatch).
|
|
2811
|
+
# So: simulate the exact tx first and keep the API executor when it passes; only
|
|
2812
|
+
# fall back to the legacy executor if the unpatched calldata reverts.
|
|
2772
2813
|
_PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2814
|
+
def _sim_swap_debt(db):
|
|
2815
|
+
base = account.encode_abi("swapDebtParaSwap", args=[
|
|
2816
|
+
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
2817
|
+
full_data[:4], db])
|
|
2818
|
+
try:
|
|
2819
|
+
w3.eth.call({"from": acct.address, "to": pa_cs,
|
|
2820
|
+
"data": base + payload.hex(), "gas": 8000000})
|
|
2821
|
+
return True, None
|
|
2822
|
+
except Exception as e:
|
|
2823
|
+
return False, str(e)
|
|
2824
|
+
sim_ok, sim_err = _sim_swap_debt(data_bytes)
|
|
2825
|
+
if sim_ok:
|
|
2826
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
2827
|
+
print(f" ✓ Executor {_exec} not in the static whitelist, but the full tx "
|
|
2828
|
+
f"simulates clean — using the API calldata as-is.")
|
|
2829
|
+
else:
|
|
2830
|
+
print(f" ✗ Simulation with API executor {_exec} reverted: {sim_err}")
|
|
2831
|
+
patched = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:]) + data_bytes[32:]
|
|
2832
|
+
sim_ok, err2 = _sim_swap_debt(patched)
|
|
2833
|
+
if sim_ok:
|
|
2834
|
+
print(f" ⚠ Falling back to legacy executor {_PARASWAP_FALLBACK_EXECUTOR} "
|
|
2835
|
+
f"(simulates clean).")
|
|
2836
|
+
data_bytes = patched
|
|
2837
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"],
|
|
2838
|
+
from_cfg["token"], borrow_amount, pa_cs)
|
|
2839
|
+
else:
|
|
2840
|
+
print(f" ✗ Legacy-executor fallback also reverted: {err2}")
|
|
2779
2841
|
|
|
2780
2842
|
from_pool, _, _ = get_pool_contract(_SYMBOL_TO_POOL[from_sym])
|
|
2781
2843
|
borrowed = from_pool.functions.getBorrowed(pa_cs).call()
|
|
@@ -2806,6 +2868,10 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2806
2868
|
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
2807
2869
|
return
|
|
2808
2870
|
|
|
2871
|
+
if not sim_ok:
|
|
2872
|
+
print("✗ Refusing to broadcast: simulation reverted for both executor variants.")
|
|
2873
|
+
return
|
|
2874
|
+
|
|
2809
2875
|
base_calldata = account.encode_abi("swapDebtParaSwap", args=[
|
|
2810
2876
|
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
2811
2877
|
full_data[:4], data_bytes,
|
|
@@ -137,6 +137,42 @@ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
|
137
137
|
# read lazily so read-only commands that don't sign never need a key at all.
|
|
138
138
|
_CLI_KEY = None # set by the --key CLI flag in main()
|
|
139
139
|
|
|
140
|
+
# Named-wallet table shared with deltaprime/arbprime. Allows running via
|
|
141
|
+
# DEGENPRIME_AGENT=parakletos (or the fallback DELTAPRIME_AGENT) which is
|
|
142
|
+
# cleaner than passing raw keys through environment variables.
|
|
143
|
+
# Agent resolution also supports --as <agent> CLI flag.
|
|
144
|
+
AGENTS = {
|
|
145
|
+
"parakletos": ("/root/.openclaw/.env", "PARAKLETOS_EVM_PRIVATE_KEY"),
|
|
146
|
+
"paraklaudios": ("/root/paraklaudios/.credentials.env", "PARAKLAUDIOS_EVM_PRIVATE_KEY"),
|
|
147
|
+
}
|
|
148
|
+
_SELECTED_AGENT = None # set by the --as CLI flag in main()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_env_var(path, var):
|
|
152
|
+
"""Return the value of `var` from a KEY=VALUE env file, or None if absent."""
|
|
153
|
+
try:
|
|
154
|
+
for line in Path(path).read_text().splitlines():
|
|
155
|
+
s = line.strip()
|
|
156
|
+
if s.startswith(var + "="):
|
|
157
|
+
return s.split("=", 1)[1].strip().strip('"').strip("'")
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
return None
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _agent_key(agent):
|
|
164
|
+
if agent not in AGENTS:
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
f"Unknown agent '{agent}'. Known agents: {', '.join(AGENTS)}. "
|
|
167
|
+
f"Or set DEGENPRIME_PRIVATE_KEY, or DEGENPRIME_KEY_FILE."
|
|
168
|
+
)
|
|
169
|
+
path, var = AGENTS[agent]
|
|
170
|
+
key = _read_env_var(path, var)
|
|
171
|
+
if not key:
|
|
172
|
+
raise RuntimeError(f"{var} not found in {path} (agent '{agent}').")
|
|
173
|
+
return key
|
|
174
|
+
|
|
175
|
+
|
|
140
176
|
# Core protocol addresses (verified on Base 2026-05-29).
|
|
141
177
|
FACTORY_PROXY = "0x5A6a0e2702cF4603a098C3Df01f3F0DF56115456" # SmartLoansFactory TUP
|
|
142
178
|
# Diamond beacon. Every Degen Account is a per-user proxy that delegates here, so the
|
|
@@ -242,27 +278,36 @@ def _tx_gas_price(w3) -> int:
|
|
|
242
278
|
|
|
243
279
|
def _set_gas_price(w3, tx_dict):
|
|
244
280
|
"""Set appropriate gas price fields for the chain, replacing the legacy gasPrice approach.
|
|
245
|
-
On EIP-1559 chains (Arbitrum, Base): sets maxFeePerGas +
|
|
246
|
-
base-fee hedge (base + prio + 1 gwei buffer).
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
281
|
+
On EIP-1559 chains (Arbitrum, Base, Avalanche post-Etna): sets maxFeePerGas +
|
|
282
|
+
maxPriorityFeePerGas with a 2x base-fee hedge (base + prio + 1 gwei buffer).
|
|
283
|
+
Falls back to legacy gasPrice only if the tx dict already lacks EIP-1559 fields
|
|
284
|
+
and the chain doesn't support max_priority_fee.
|
|
285
|
+
(25 gwei was the pre-Etna C-chain minimum; ACP-125 (Dec 2024) lowered the min base
|
|
286
|
+
fee to 1 nAVAX — base now sits at ~0.01 nAVAX, so a 25 gwei floor overpaid ~2500x
|
|
287
|
+
and inflated the upfront balance requirement past small EOAs.)"""
|
|
288
|
+
# If build_transaction already set EIP-1559 fields, don't touch them
|
|
289
|
+
if "maxFeePerGas" in tx_dict or "maxPriorityFeePerGas" in tx_dict:
|
|
290
|
+
tx_dict.pop("gasPrice", None)
|
|
291
|
+
return
|
|
251
292
|
tx_dict.pop("gasPrice", None)
|
|
252
|
-
|
|
293
|
+
try:
|
|
253
294
|
base = w3.eth.gas_price
|
|
254
295
|
prio = w3.eth.max_priority_fee
|
|
255
296
|
tx_dict["maxFeePerGas"] = max(int(base * 2), base + prio + 10**9)
|
|
256
297
|
tx_dict["maxPriorityFeePerGas"] = prio
|
|
257
|
-
|
|
298
|
+
except Exception:
|
|
299
|
+
# Legacy chain — use gasPrice instead
|
|
258
300
|
tx_dict["gasPrice"] = max(int(w3.eth.gas_price * 2), 1 * 10**9)
|
|
259
301
|
def resolve_private_key():
|
|
260
302
|
"""Resolve the signing key per the documented precedence:
|
|
261
303
|
1. --key <0xhex> CLI flag
|
|
262
|
-
2.
|
|
263
|
-
3.
|
|
264
|
-
4.
|
|
265
|
-
|
|
304
|
+
2. --as <agent> CLI flag
|
|
305
|
+
3. DEGENPRIME_PRIVATE_KEY env var
|
|
306
|
+
4. DEGENPRIME_KEY_FILE env var (path to a file containing the 0x key)
|
|
307
|
+
5. DELTAPRIME_PRIVATE_KEY / DELTAPRIME_KEY_FILE (same key, both chains)
|
|
308
|
+
6. DEGENPRIME_AGENT env var
|
|
309
|
+
7. DELTAPRIME_AGENT env var (fallback)
|
|
310
|
+
Raises with a clear message if none resolve."""
|
|
266
311
|
if _CLI_KEY:
|
|
267
312
|
return _CLI_KEY.strip()
|
|
268
313
|
for env_var in ("DEGENPRIME_PRIVATE_KEY", "DELTAPRIME_PRIVATE_KEY"):
|
|
@@ -276,6 +321,11 @@ def resolve_private_key():
|
|
|
276
321
|
return Path(key_file).read_text().strip()
|
|
277
322
|
except FileNotFoundError:
|
|
278
323
|
raise RuntimeError(f"{path_var} points at {key_file} but the file does not exist.")
|
|
324
|
+
# Named agent via env var
|
|
325
|
+
for ag in ("DEGENPRIME_AGENT", "DELTAPRIME_AGENT"):
|
|
326
|
+
agent = os.environ.get(ag)
|
|
327
|
+
if agent:
|
|
328
|
+
return _agent_key(agent)
|
|
279
329
|
raise RuntimeError(
|
|
280
330
|
"No signing key found. Set DEGENPRIME_PRIVATE_KEY (raw 0x... key) or "
|
|
281
331
|
"DEGENPRIME_KEY_FILE (path to a file with the key), or pass --key <0xhex>. "
|
|
@@ -1983,12 +2033,39 @@ def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.
|
|
|
1983
2033
|
selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
|
|
1984
2034
|
_exec, _src, _dest, _from_amt, min_out = _paraswap_decode_and_check(
|
|
1985
2035
|
selector_hex, data_bytes, from_cfg["token"], to_cfg["token"], amount_in, pa_cs)
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2036
|
+
# Simulate-first executor handling (see cmd_swap_debt rationale): keep the API
|
|
2037
|
+
# executor when the exact tx simulates clean; only fall back to the legacy
|
|
2038
|
+
# executor if the unpatched calldata reverts.
|
|
2039
|
+
feeds = degen_account_price_feeds(account)
|
|
2040
|
+
for s in (from_sym, to_sym):
|
|
2041
|
+
if s in REDSTONE_AVAILABLE_FEEDS and s not in feeds:
|
|
2042
|
+
feeds.append(s)
|
|
2043
|
+
payload = build_redstone_payload(feeds)
|
|
2044
|
+
def _sim_paraswap(db):
|
|
2045
|
+
base = account.encode_abi("paraSwapV6", args=[full[:4], db])
|
|
2046
|
+
try:
|
|
2047
|
+
w3.eth.call({"from": acct.address, "to": pa_cs,
|
|
2048
|
+
"data": base + payload.hex(), "gas": 8000000})
|
|
2049
|
+
return True, None
|
|
2050
|
+
except Exception as e:
|
|
2051
|
+
return False, str(e)
|
|
2052
|
+
sim_ok, sim_err = _sim_paraswap(data_bytes)
|
|
2053
|
+
if sim_ok:
|
|
2054
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
2055
|
+
print(f" ✓ Executor {_exec} not in the static whitelist, but the full tx "
|
|
2056
|
+
f"simulates clean — using the API calldata as-is.")
|
|
2057
|
+
else:
|
|
2058
|
+
print(f" ✗ Simulation with API executor {_exec} reverted: {sim_err}")
|
|
2059
|
+
patched = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:]) + data_bytes[32:]
|
|
2060
|
+
sim_ok, err2 = _sim_paraswap(patched)
|
|
2061
|
+
if sim_ok:
|
|
2062
|
+
print(f" ⚠ Falling back to legacy executor {_PARASWAP_FALLBACK_EXECUTOR} "
|
|
2063
|
+
f"(simulates clean).")
|
|
2064
|
+
data_bytes = patched
|
|
2065
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"],
|
|
2066
|
+
to_cfg["token"], amount_in, pa_cs)
|
|
2067
|
+
else:
|
|
2068
|
+
print(f" ✗ Legacy-executor fallback also reverted: {err2}")
|
|
1992
2069
|
|
|
1993
2070
|
print(f"Swap {amount} {from_sym} -> {to_sym} on Degen Account {pa_cs} (via ParaSwap/Velora)")
|
|
1994
2071
|
print(f" Router method: {price_route['contractMethod']} ({selector_hex})")
|
|
@@ -2003,10 +2080,12 @@ def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.
|
|
|
2003
2080
|
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
2004
2081
|
return
|
|
2005
2082
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2083
|
+
if not sim_ok:
|
|
2084
|
+
print("✗ Refusing to broadcast: simulation reverted for both executor variants.")
|
|
2085
|
+
return
|
|
2086
|
+
|
|
2087
|
+
# Rebuild the payload fresh for broadcast (the sim payload may be near the
|
|
2088
|
+
# RedStone staleness window by now).
|
|
2010
2089
|
payload = build_redstone_payload(feeds)
|
|
2011
2090
|
base_calldata = account.encode_abi("paraSwapV6", args=[full[:4], data_bytes])
|
|
2012
2091
|
data = base_calldata + payload.hex()
|
|
@@ -2112,12 +2191,36 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2112
2191
|
selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
|
|
2113
2192
|
_exec, _src, _dest, _swap_from_amt, swap_min_out = _paraswap_decode_and_check(
|
|
2114
2193
|
selector_hex, data_bytes, to_cfg["token"], from_cfg["token"], borrow_amount, pa_cs)
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2194
|
+
# Simulate-first executor handling (protocol-level facet fix confirmed 2026-06-04;
|
|
2195
|
+
# Velora rotates executors per quote): keep the API executor when the exact tx
|
|
2196
|
+
# simulates clean; only fall back to the legacy executor if it reverts.
|
|
2197
|
+
def _sim_swap_debt(db):
|
|
2198
|
+
base = account.encode_abi("swapDebtParaSwap", args=[
|
|
2199
|
+
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
2200
|
+
full[:4], db])
|
|
2201
|
+
try:
|
|
2202
|
+
w3.eth.call({"from": acct.address, "to": pa_cs,
|
|
2203
|
+
"data": base + payload.hex(), "gas": 8000000})
|
|
2204
|
+
return True, None
|
|
2205
|
+
except Exception as e:
|
|
2206
|
+
return False, str(e)
|
|
2207
|
+
sim_ok, sim_err = _sim_swap_debt(data_bytes)
|
|
2208
|
+
if sim_ok:
|
|
2209
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
2210
|
+
print(f" ✓ Executor {_exec} not in the static whitelist, but the full tx "
|
|
2211
|
+
f"simulates clean — using the API calldata as-is.")
|
|
2212
|
+
else:
|
|
2213
|
+
print(f" ✗ Simulation with API executor {_exec} reverted: {sim_err}")
|
|
2214
|
+
patched = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:]) + data_bytes[32:]
|
|
2215
|
+
sim_ok, err2 = _sim_swap_debt(patched)
|
|
2216
|
+
if sim_ok:
|
|
2217
|
+
print(f" ⚠ Falling back to legacy executor {_PARASWAP_FALLBACK_EXECUTOR} "
|
|
2218
|
+
f"(simulates clean).")
|
|
2219
|
+
data_bytes = patched
|
|
2220
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"],
|
|
2221
|
+
from_cfg["token"], borrow_amount, pa_cs)
|
|
2222
|
+
else:
|
|
2223
|
+
print(f" ✗ Legacy-executor fallback also reverted: {err2}")
|
|
2121
2224
|
|
|
2122
2225
|
print(f"Swap debt on Degen Account {pa}")
|
|
2123
2226
|
print(f" Refinance: {from_sym} debt -> {to_sym} debt")
|
|
@@ -2145,6 +2248,10 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2145
2248
|
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
2146
2249
|
return
|
|
2147
2250
|
|
|
2251
|
+
if not sim_ok:
|
|
2252
|
+
print("✗ Refusing to broadcast: simulation reverted for both executor variants.")
|
|
2253
|
+
return
|
|
2254
|
+
|
|
2148
2255
|
base_calldata = account.encode_abi("swapDebtParaSwap", args=[
|
|
2149
2256
|
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
2150
2257
|
full[:4], data_bytes,
|
|
@@ -299,16 +299,16 @@ PARASWAP_AUGUSTUS = "0x6A000F20005980200259B80c5102003040001068"
|
|
|
299
299
|
PARASWAP_SUPPORTED_SELECTORS = {"0xe3ead59e", "0x876a02f6"}
|
|
300
300
|
# Executors the facet whitelists (ParaSwapHelper._checkExecutorAddress). Lowercased.
|
|
301
301
|
PARASWAP_EXECUTORS = {
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
302
|
+
# Historical static whitelist. Since the protocol-level facet fix (confirmed
|
|
303
|
+
# 2026-06-04), API-returned executors outside this set can be VALID — Velora
|
|
304
|
+
# rotates executors per quote (seen: 0x8faa…e820, 0x6f05…0900). The swap paths
|
|
305
|
+
# now decide by eth_call simulation of the exact tx, not by this set; it's kept
|
|
306
|
+
# only to label "known" vs "new" executors in output.
|
|
306
307
|
"0xdef171fe48cf0115b1d80b88dc8eab59176fee57",
|
|
307
308
|
"0x6a000f20005980200259b80c5102003040001068",
|
|
308
309
|
"0x000010036c0190e009a000d0fc3541100a07380a",
|
|
309
310
|
"0x00c600b30fb0400701010f4b080409018b9006e0",
|
|
310
311
|
"0xa0f408a000017007015e0f00320e470d00090a5b",
|
|
311
|
-
# 0x8faa... (Velora) returned by API but NOT whitelisted on-chain — confirmed 2026-05-28 & 2026-05-29
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
# RedStone on-demand oracle config for DeltaPrime on Avalanche. The Prime Account's
|
|
@@ -2352,14 +2352,40 @@ def _swap_via_paraswap(w3, acct, pa_cs, account, from_sym, to_sym, from_cfg, to_
|
|
|
2352
2352
|
selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
|
|
2353
2353
|
_exec, _src, _dest, from_amt, min_out = _paraswap_decode_and_check(
|
|
2354
2354
|
selector_hex, data_bytes, from_cfg["token"], to_cfg["token"], amount_in, pa_cs)
|
|
2355
|
-
# Same
|
|
2355
|
+
# Same simulate-first executor handling as swap-debt (see cmd_swap_debt for full
|
|
2356
|
+
# rationale): keep the API executor when the exact tx simulates clean; only fall
|
|
2357
|
+
# back to the legacy executor if the unpatched calldata reverts.
|
|
2356
2358
|
_PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2359
|
+
feeds = prime_account_price_feeds(account)
|
|
2360
|
+
for s in (from_sym, to_sym):
|
|
2361
|
+
if s not in feeds:
|
|
2362
|
+
feeds.append(s)
|
|
2363
|
+
payload = build_redstone_payload(feeds)
|
|
2364
|
+
def _sim_paraswap(db):
|
|
2365
|
+
base = account.encode_abi("paraSwapV6", args=[full[:4], db])
|
|
2366
|
+
try:
|
|
2367
|
+
w3.eth.call({"from": acct.address, "to": pa_cs,
|
|
2368
|
+
"data": base + payload.hex(), "gas": 8000000})
|
|
2369
|
+
return True, None
|
|
2370
|
+
except Exception as e:
|
|
2371
|
+
return False, str(e)
|
|
2372
|
+
sim_ok, sim_err = _sim_paraswap(data_bytes)
|
|
2373
|
+
if sim_ok:
|
|
2374
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
2375
|
+
print(f" ✓ Executor {_exec} not in the static whitelist, but the full tx "
|
|
2376
|
+
f"simulates clean — using the API calldata as-is.")
|
|
2377
|
+
else:
|
|
2378
|
+
print(f" ✗ Simulation with API executor {_exec} reverted: {sim_err}")
|
|
2379
|
+
patched = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:]) + data_bytes[32:]
|
|
2380
|
+
sim_ok, err2 = _sim_paraswap(patched)
|
|
2381
|
+
if sim_ok:
|
|
2382
|
+
print(f" ⚠ Falling back to legacy executor {_PARASWAP_FALLBACK_EXECUTOR} "
|
|
2383
|
+
f"(simulates clean).")
|
|
2384
|
+
data_bytes = patched
|
|
2385
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"],
|
|
2386
|
+
to_cfg["token"], amount_in, pa_cs)
|
|
2387
|
+
else:
|
|
2388
|
+
print(f" ✗ Legacy-executor fallback also reverted: {err2}")
|
|
2363
2389
|
|
|
2364
2390
|
print(f"Swap {amount} {from_sym} -> {to_sym} on Prime Account {pa_cs} (via ParaSwap/Velora)")
|
|
2365
2391
|
print(f" Router method: {price_route['contractMethod']} ({selector_hex})")
|
|
@@ -2374,10 +2400,12 @@ def _swap_via_paraswap(w3, acct, pa_cs, account, from_sym, to_sym, from_cfg, to_
|
|
|
2374
2400
|
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
2375
2401
|
return
|
|
2376
2402
|
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2403
|
+
if not sim_ok:
|
|
2404
|
+
print("✗ Refusing to broadcast: simulation reverted for both executor variants.")
|
|
2405
|
+
return
|
|
2406
|
+
|
|
2407
|
+
# Rebuild the payload fresh for broadcast (the sim payload may be near the
|
|
2408
|
+
# RedStone staleness window by now).
|
|
2381
2409
|
payload = build_redstone_payload(feeds)
|
|
2382
2410
|
base_calldata = account.encode_abi("paraSwapV6", args=[full[:4], data_bytes])
|
|
2383
2411
|
data = base_calldata + payload.hex()
|
|
@@ -2570,7 +2598,8 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2570
2598
|
|
|
2571
2599
|
Default (one-tx): SwapDebtFacet.swapDebtParaSwap — borrows _borrowAmount of _toAsset,
|
|
2572
2600
|
ParaSwaps it into _fromAsset, and repays _repayAmount of _fromAsset debt in a single tx.
|
|
2573
|
-
|
|
2601
|
+
(Was broken on-chain 2026-05-30 by a protocol-level Velora/ParaSwap facet bug; the
|
|
2602
|
+
DeltaPrime team fixed it — re-verified working via eth_call + live tx 2026-06-04.)
|
|
2574
2603
|
|
|
2575
2604
|
--fallback (manual 3-tx via YieldYak):
|
|
2576
2605
|
1. borrow to_sym — borrow the new debt asset into the account
|
|
@@ -2792,16 +2821,40 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2792
2821
|
_exec, _src, _dest, swap_from_amt, swap_min_out = _paraswap_decode_and_check(
|
|
2793
2822
|
selector_hex, data_bytes, to_cfg["token"], from_cfg["token"], borrow_amount, pa_cs)
|
|
2794
2823
|
|
|
2795
|
-
#
|
|
2796
|
-
#
|
|
2797
|
-
#
|
|
2824
|
+
# Velora/ParaSwap executors rotate per quote and the facet's on-chain executor
|
|
2825
|
+
# check was fixed at the protocol level (DeltaPrime team, confirmed by eth_call
|
|
2826
|
+
# 2026-06-04) — API-built calldata now passes with its own executor, while the old
|
|
2827
|
+
# hard-patch to the legacy executor REVERTS (executor-specific calldata mismatch).
|
|
2828
|
+
# So: simulate the exact tx first and keep the API executor when it passes; only
|
|
2829
|
+
# fall back to the legacy executor if the unpatched calldata reverts.
|
|
2798
2830
|
_PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2831
|
+
def _sim_swap_debt(db):
|
|
2832
|
+
base = account.encode_abi("swapDebtParaSwap", args=[
|
|
2833
|
+
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
2834
|
+
full_data[:4], db])
|
|
2835
|
+
try:
|
|
2836
|
+
w3.eth.call({"from": acct.address, "to": pa_cs,
|
|
2837
|
+
"data": base + payload.hex(), "gas": 8000000})
|
|
2838
|
+
return True, None
|
|
2839
|
+
except Exception as e:
|
|
2840
|
+
return False, str(e)
|
|
2841
|
+
sim_ok, sim_err = _sim_swap_debt(data_bytes)
|
|
2842
|
+
if sim_ok:
|
|
2843
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
2844
|
+
print(f" ✓ Executor {_exec} not in the static whitelist, but the full tx "
|
|
2845
|
+
f"simulates clean — using the API calldata as-is.")
|
|
2846
|
+
else:
|
|
2847
|
+
print(f" ✗ Simulation with API executor {_exec} reverted: {sim_err}")
|
|
2848
|
+
patched = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:]) + data_bytes[32:]
|
|
2849
|
+
sim_ok, err2 = _sim_swap_debt(patched)
|
|
2850
|
+
if sim_ok:
|
|
2851
|
+
print(f" ⚠ Falling back to legacy executor {_PARASWAP_FALLBACK_EXECUTOR} "
|
|
2852
|
+
f"(simulates clean).")
|
|
2853
|
+
data_bytes = patched
|
|
2854
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"],
|
|
2855
|
+
from_cfg["token"], borrow_amount, pa_cs)
|
|
2856
|
+
else:
|
|
2857
|
+
print(f" ✗ Legacy-executor fallback also reverted: {err2}")
|
|
2805
2858
|
|
|
2806
2859
|
from_pool, _, _ = get_pool_contract(_SYMBOL_TO_POOL[from_sym])
|
|
2807
2860
|
borrowed = from_pool.functions.getBorrowed(pa_cs).call()
|
|
@@ -2832,6 +2885,10 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
|
|
|
2832
2885
|
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
2833
2886
|
return
|
|
2834
2887
|
|
|
2888
|
+
if not sim_ok:
|
|
2889
|
+
print("✗ Refusing to broadcast: simulation reverted for both executor variants.")
|
|
2890
|
+
return
|
|
2891
|
+
|
|
2835
2892
|
base_calldata = account.encode_abi("swapDebtParaSwap", args=[
|
|
2836
2893
|
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
2837
2894
|
full_data[:4], data_bytes,
|
|
@@ -37,22 +37,21 @@ TIER_MAX = {"basic": 5, "premium": 10}
|
|
|
37
37
|
# ════════════════════════════════════════════════════════════════════
|
|
38
38
|
|
|
39
39
|
def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
|
|
40
|
-
"""Compute
|
|
40
|
+
"""Compute health (0-100%) using the frontend formula from DeltaPrime docs.
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
Uses the cross-margin health formula (all assets assumed same borrowing power):
|
|
43
|
+
equity = total_supplied_usd - total_debt_usd
|
|
44
|
+
health_pct = 100 * (1 - debt / (max_mult * equity))
|
|
45
|
+
(0% = liquidation, 100% = no debt)
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
Background:
|
|
48
|
+
The frontend uses Pr = tier / (tier + 1) and computes:
|
|
49
|
+
health_pct = (Pr * supplied - debt) / (Pr * equity) * 100
|
|
50
|
+
which simplifies to: 100 * (1 - debt / (max_mult * equity)).
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
(1.0=liquidation
|
|
53
|
-
measurement (0%=liquidation, 50%=half borrowing power used, 100%=no debt).
|
|
54
|
-
|
|
55
|
-
Returns dict with health metrics or error.
|
|
52
|
+
DIFFERENT from the equity-based "health_pct" in defi --json / prime-summary
|
|
53
|
+
(which uses max_debt = equity * (tier - 1)).
|
|
54
|
+
The on-chain health_ratio (1.0=liquidation) is NOT used here.
|
|
56
55
|
"""
|
|
57
56
|
# Parse groups (DeltaPrime format) or flat format (DegenPrime)
|
|
58
57
|
groups = defi_data.get("groups", [])
|
|
@@ -62,24 +61,27 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
|
|
|
62
61
|
borrowed = g.get("borrowed", [])
|
|
63
62
|
health_ratio = g.get("health_ratio", 0) or 0
|
|
64
63
|
# Use precomputed health_pct from defi --json if available (primecli >= 0.5.4)
|
|
65
|
-
precomputed = g.get("health_pct")
|
|
64
|
+
precomputed = g.get("health_pct")
|
|
66
65
|
if precomputed is not None:
|
|
67
|
-
#
|
|
66
|
+
# Override health_pct with frontend formula (ignores precomputed value)
|
|
68
67
|
supplied_usd = sum(s.get("usd", 0) or 0 for s in supplied)
|
|
69
68
|
debt_usd = sum(b.get("usd", 0) or 0 for b in borrowed)
|
|
70
|
-
equity = supplied_usd - debt_usd
|
|
69
|
+
equity = max(supplied_usd - debt_usd, 0.01)
|
|
71
70
|
raw_usdc = sum(s.get("usd", 0) for s in supplied if s.get("symbol") == "USDC")
|
|
72
71
|
symbols = [s.get("symbol", "") for s in supplied]
|
|
73
|
-
has_gmx =
|
|
72
|
+
has_gmx = sum(s.get("usd", 0) for s in supplied if "GM_" in s.get("symbol", "")) > 1.0
|
|
74
73
|
has_lb = any(sym in ("LB_AVAX_USDC", "LB_WAVAX_USDC", "JOE") or "TRADERJOE" in sym.upper() for sym in symbols)
|
|
75
74
|
has_aero = any("AERO" in sym.upper() or "CL_POSITION" in sym.upper() for sym in symbols)
|
|
75
|
+
# Frontend formula: health_pct = 100 * (1 - debt / (max_mult * equity))
|
|
76
|
+
fe_health = max(0.0, 100.0 * (1.0 - round(debt_usd, 2) / (max_mult * equity)))
|
|
77
|
+
fe_max_debt = round(max_mult * equity, 2)
|
|
76
78
|
return {
|
|
77
|
-
"health_pct":
|
|
79
|
+
"health_pct": round(fe_health, 1),
|
|
78
80
|
"health_ratio": round(health_ratio, 4),
|
|
79
81
|
"supplied_usd": round(supplied_usd, 2),
|
|
80
82
|
"debt_usd": round(debt_usd, 2),
|
|
81
83
|
"equity": round(equity, 2),
|
|
82
|
-
"max_debt": round(max(0,
|
|
84
|
+
"max_debt": round(max(0, max_mult * equity), 2),
|
|
83
85
|
"raw_usdc": round(raw_usdc, 2),
|
|
84
86
|
"has_gmx": has_gmx,
|
|
85
87
|
"has_lb": has_lb,
|
|
@@ -105,14 +107,14 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
|
|
|
105
107
|
"error": "equity near zero",
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
max_debt =
|
|
110
|
+
max_debt = round(max_mult * equity, 2) # frontend formula: max debt before liquidation
|
|
109
111
|
|
|
110
112
|
# Raw USDC in account
|
|
111
113
|
raw_usdc = sum(s.get("usd", 0) for s in supplied if s.get("symbol") == "USDC")
|
|
112
114
|
|
|
113
115
|
# Position type detection
|
|
114
116
|
symbols = [s.get("symbol", "") for s in supplied]
|
|
115
|
-
has_gmx =
|
|
117
|
+
has_gmx = sum(s.get("usd", 0) for s in supplied if "GM_" in s.get("symbol", "")) > 1.0
|
|
116
118
|
has_lb = any(
|
|
117
119
|
sym in ("LB_AVAX_USDC", "LB_WAVAX_USDC", "JOE")
|
|
118
120
|
or "TRADERJOE" in sym.upper()
|
|
@@ -120,13 +122,13 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
|
|
|
120
122
|
)
|
|
121
123
|
has_aero = any("AERO" in sym.upper() or "CL_POSITION" in sym.upper() for sym in symbols)
|
|
122
124
|
|
|
123
|
-
if max_debt > 0 and debt_usd >= 0:
|
|
124
|
-
health_pct = (
|
|
125
|
-
health_pct = max(0.0, min(100.0, health_pct))
|
|
125
|
+
if max_debt > 0.01 and debt_usd >= 0:
|
|
126
|
+
health_pct = max(0.0, 100.0 * (1.0 - round(debt_usd, 2) / max_debt))
|
|
126
127
|
else:
|
|
127
128
|
health_pct = 100.0
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
# Center target (50% health): target_debt = max_debt * 0.5
|
|
131
|
+
delta_debt = (max_debt * 0.5) - debt_usd
|
|
130
132
|
|
|
131
133
|
return {
|
|
132
134
|
"health_pct": round(health_pct, 1),
|
|
@@ -306,28 +308,32 @@ def run_tick(
|
|
|
306
308
|
max_mult = TIER_MAX.get("basic", 5)
|
|
307
309
|
tier = "basic"
|
|
308
310
|
else:
|
|
309
|
-
max_mult = TIER_MAX.get("
|
|
310
|
-
tier = "
|
|
311
|
+
max_mult = TIER_MAX.get("basic", 5)
|
|
312
|
+
tier = "basic"
|
|
311
313
|
except Exception:
|
|
312
|
-
max_mult =
|
|
313
|
-
tier = "
|
|
314
|
+
max_mult = 5
|
|
315
|
+
tier = "basic"
|
|
314
316
|
|
|
315
317
|
# 3. Compute health
|
|
316
318
|
health = compute_health(defi_data, max_mult)
|
|
317
319
|
health["tier"] = tier
|
|
318
320
|
if health.get("error") == "equity near zero":
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
321
|
+
# Only escalate if there's actual debt — an empty unfunded wallet is not an emergency
|
|
322
|
+
if health.get("debt_usd", 0) and health["debt_usd"] > 0.5:
|
|
323
|
+
write_escalation(state_dir, "equity-near-zero", {
|
|
324
|
+
"reason": "equity_near_zero",
|
|
325
|
+
"equity": health["equity"],
|
|
326
|
+
"debt": health["debt_usd"],
|
|
327
|
+
"health_pct": health["health_pct"],
|
|
328
|
+
"label": label,
|
|
329
|
+
})
|
|
330
|
+
result["mode"] = "escalated"
|
|
331
|
+
else:
|
|
332
|
+
result["action"] = "none (unfunded account)"
|
|
326
333
|
result.update(health)
|
|
327
|
-
result["mode"] = "escalated"
|
|
328
334
|
return result
|
|
329
335
|
|
|
330
|
-
# 4. Load strategy
|
|
336
|
+
# 4. Load strategy (position/market/side are optional hints now — auto-detected from defi_data)
|
|
331
337
|
strategy = load_strategy(strategy_path)
|
|
332
338
|
mode = strategy.get("mode", "observer")
|
|
333
339
|
health["mode"] = mode
|
|
@@ -390,7 +396,7 @@ def run_tick(
|
|
|
390
396
|
pct = health["health_pct"]
|
|
391
397
|
equity = health["equity"]
|
|
392
398
|
debt = health["debt_usd"]
|
|
393
|
-
raw_usdc = health
|
|
399
|
+
raw_usdc = health.get("raw_usdc", 0)
|
|
394
400
|
|
|
395
401
|
# ── Stop-loss: equity drawdown ──────────────────────────────
|
|
396
402
|
if stop_loss_drawdown > 0:
|
|
@@ -513,32 +519,86 @@ def run_tick(
|
|
|
513
519
|
result["error"] = f"borrow error: {e}"
|
|
514
520
|
return result
|
|
515
521
|
|
|
516
|
-
# Deploy into
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
# Partial state: borrowed but deposit failed
|
|
530
|
-
result["warning"] = f"borrow ok but deposit failed: {r.stderr[:200]}"
|
|
531
|
-
result["action"] = "partial (borrowed, deposit failed)"
|
|
532
|
-
except Exception as e:
|
|
533
|
-
result["error"] = f"gmx deposit error: {e}"
|
|
522
|
+
# Deploy into whatever positions are open (detected dynamically from defi_data)
|
|
523
|
+
has_gmx = health.get("has_gmx", False)
|
|
524
|
+
has_lb = health.get("has_lb", False)
|
|
525
|
+
has_aero = health.get("has_aero", False)
|
|
526
|
+
open_positions = []
|
|
527
|
+
if has_gmx: open_positions.append("gmx")
|
|
528
|
+
if has_lb: open_positions.append("lb")
|
|
529
|
+
if has_aero: open_positions.append("aero")
|
|
530
|
+
|
|
531
|
+
if not open_positions:
|
|
532
|
+
# No open positions — just borrow and leave as USDC (or deploy to default)
|
|
533
|
+
result["action"] = f"borrowed ${borrow_amt:.2f} (no positions to deploy into)"
|
|
534
|
+
cooldown_file.write_text(str(int(time.time())))
|
|
534
535
|
else:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
536
|
+
# Split borrow amount proportionally across open positions
|
|
537
|
+
split_amt = borrow_amt / len(open_positions)
|
|
538
|
+
deployed_ok = 0
|
|
539
|
+
deployed_fail = 0
|
|
540
|
+
|
|
541
|
+
for pos_type in open_positions:
|
|
542
|
+
if pos_type == "gmx":
|
|
543
|
+
# Use market/side from strategy as hint, fall back to sensible defaults
|
|
544
|
+
mkt = strategy.get("market", "avax-usdc") if tool_path else "avax-usdc"
|
|
545
|
+
sd = strategy.get("side", "long") if tool_path else "long"
|
|
546
|
+
try:
|
|
547
|
+
r = subprocess.run(
|
|
548
|
+
[sys.executable, tool_path, "gmx-deposit",
|
|
549
|
+
"--market", mkt, "--amount", f"{split_amt:.2f}",
|
|
550
|
+
"--side", sd, "--fee-buffer", "1.5", "--execute"],
|
|
551
|
+
capture_output=True, text=True, timeout=120,
|
|
552
|
+
)
|
|
553
|
+
if r.returncode == 0:
|
|
554
|
+
deployed_ok += 1
|
|
555
|
+
else:
|
|
556
|
+
result["warning"] = f"gmx deposit failed: {r.stderr[:200]}"
|
|
557
|
+
deployed_fail += 1
|
|
558
|
+
except Exception as e:
|
|
559
|
+
result["error"] = f"gmx deposit error: {e}"
|
|
560
|
+
deployed_fail += 1
|
|
561
|
+
|
|
562
|
+
elif pos_type == "lb":
|
|
563
|
+
# LB deposits: use the pool from strategy hint, default to AVAX/USDC
|
|
564
|
+
lb_pool = strategy.get("lb_pool", "AVAX/USDC")
|
|
565
|
+
try:
|
|
566
|
+
r = subprocess.run(
|
|
567
|
+
[sys.executable, tool_path, "lb-deposit",
|
|
568
|
+
"--pool", lb_pool, "--amount", f"{split_amt:.2f}",
|
|
569
|
+
"--execute"],
|
|
570
|
+
capture_output=True, text=True, timeout=120,
|
|
571
|
+
)
|
|
572
|
+
if r.returncode == 0:
|
|
573
|
+
deployed_ok += 1
|
|
574
|
+
else:
|
|
575
|
+
result["warning"] = f"lb deposit failed: {r.stderr[:200]}"
|
|
576
|
+
deployed_fail += 1
|
|
577
|
+
except Exception as e:
|
|
578
|
+
result["error"] = f"lb deposit error: {e}"
|
|
579
|
+
deployed_fail += 1
|
|
580
|
+
|
|
581
|
+
elif pos_type == "aero":
|
|
582
|
+
try:
|
|
583
|
+
r = subprocess.run(
|
|
584
|
+
[sys.executable, tool_path, "aerodrome-deposit",
|
|
585
|
+
"--amount", f"{split_amt:.2f}", "--execute"],
|
|
586
|
+
capture_output=True, text=True, timeout=120,
|
|
587
|
+
)
|
|
588
|
+
if r.returncode == 0:
|
|
589
|
+
deployed_ok += 1
|
|
590
|
+
else:
|
|
591
|
+
result["warning"] = f"aerodrome deposit failed: {r.stderr[:200]}"
|
|
592
|
+
deployed_fail += 1
|
|
593
|
+
except Exception as e:
|
|
594
|
+
result["error"] = f"aerodrome deposit error: {e}"
|
|
595
|
+
deployed_fail += 1
|
|
596
|
+
|
|
597
|
+
if deployed_ok > 0:
|
|
598
|
+
cooldown_file.write_text(str(int(time.time())))
|
|
599
|
+
result["action"] = f"borrowed ${borrow_amt:.2f}, deployed ${split_amt:.2f} to {deployed_ok} position(s)"
|
|
600
|
+
else:
|
|
601
|
+
result["warning"] = f"borrow ok but all deposits failed"
|
|
542
602
|
|
|
543
603
|
return result
|
|
544
604
|
|
|
@@ -573,7 +633,7 @@ def cli():
|
|
|
573
633
|
[sys.executable, tool_path, "defi", "--json"],
|
|
574
634
|
capture_output=True, text=True, timeout=90,
|
|
575
635
|
)
|
|
576
|
-
tier = "
|
|
636
|
+
tier = "basic" # default
|
|
577
637
|
try:
|
|
578
638
|
t = subprocess.run(
|
|
579
639
|
[sys.executable, tool_path, "prime-tier"],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: primecli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.7
|
|
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.
|
|
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).
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "primecli"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.7"
|
|
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"
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Offline tests for `_set_gas_price_for(chain_id, w3, tx)` in deltaprime and arbprime.
|
|
2
2
|
|
|
3
|
-
This helper sets gas fields for
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
This helper sets gas fields for cross-chain flows (like prime-bridge). All three
|
|
4
|
+
supported chains (Arbitrum 42161, Base 8453, Avalanche 43114 post-Etna) speak
|
|
5
|
+
EIP-1559, so the helper now tries EIP-1559 first regardless of chain id:
|
|
6
|
+
* `eth.max_priority_fee` works → maxFeePerGas + maxPriorityFeePerGas,
|
|
6
7
|
and NO legacy gasPrice.
|
|
7
|
-
*
|
|
8
|
+
* `eth.max_priority_fee` raises (legacy-only chain/RPC) → legacy gasPrice with
|
|
9
|
+
a 1 gwei floor, and NO EIP-1559 fields.
|
|
10
|
+
* EIP-1559 fields already present on the tx → left untouched (stale gasPrice dropped).
|
|
8
11
|
|
|
9
12
|
No RPC is made: we feed a stub w3 whose `eth.gas_price` / `eth.max_priority_fee`
|
|
10
13
|
return canned values. The helper is duplicated in both modules, so both are tested.
|
|
@@ -24,7 +27,13 @@ MODULES = ["primecli.deltaprime", "primecli.arbprime"]
|
|
|
24
27
|
class _StubEth:
|
|
25
28
|
def __init__(self, gas_price, max_priority_fee):
|
|
26
29
|
self.gas_price = gas_price
|
|
27
|
-
self.
|
|
30
|
+
self._max_priority_fee = max_priority_fee
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def max_priority_fee(self):
|
|
34
|
+
if isinstance(self._max_priority_fee, Exception):
|
|
35
|
+
raise self._max_priority_fee
|
|
36
|
+
return self._max_priority_fee
|
|
28
37
|
|
|
29
38
|
|
|
30
39
|
class _StubW3:
|
|
@@ -82,24 +91,46 @@ def test_base_sets_eip1559_no_gasprice(mod):
|
|
|
82
91
|
|
|
83
92
|
|
|
84
93
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
85
|
-
# Avalanche (43114) —
|
|
94
|
+
# Avalanche (43114) — EIP-1559 post-Etna (ACP-125); legacy gasPrice only as fallback
|
|
86
95
|
|
|
87
96
|
|
|
88
|
-
def
|
|
89
|
-
# gas_price*2 (60 gwei) > 1 gwei floor → uses doubled value
|
|
97
|
+
def test_avalanche_uses_eip1559_when_priority_fee_available(mod):
|
|
90
98
|
w3 = _StubW3(gas_price=30 * GWEI, max_priority_fee=1 * GWEI)
|
|
91
|
-
tx = {}
|
|
99
|
+
tx = {"gasPrice": 1} # stale value dropped, not added-to
|
|
92
100
|
mod._set_gas_price_for(43114, w3, tx)
|
|
93
|
-
assert
|
|
94
|
-
|
|
95
|
-
assert "
|
|
101
|
+
assert "gasPrice" not in tx
|
|
102
|
+
# max(base*2, base + prio + 1gwei) = max(60, 32) = 60 gwei
|
|
103
|
+
assert tx["maxFeePerGas"] == 60 * GWEI
|
|
104
|
+
assert tx["maxPriorityFeePerGas"] == 1 * GWEI
|
|
96
105
|
|
|
97
106
|
|
|
98
|
-
def
|
|
99
|
-
#
|
|
100
|
-
|
|
107
|
+
def test_legacy_fallback_applies_1_gwei_floor(mod):
|
|
108
|
+
# max_priority_fee unsupported (raises) → legacy gasPrice path with the 1 gwei
|
|
109
|
+
# floor: gas_price*2 (0.02 gwei, realistic post-Etna base) < 1 gwei → floor wins
|
|
110
|
+
w3 = _StubW3(gas_price=GWEI // 100, max_priority_fee=ValueError("no eip-1559"))
|
|
101
111
|
tx = {"gasPrice": 1} # stale value replaced, not added-to
|
|
102
112
|
mod._set_gas_price_for(43114, w3, tx)
|
|
103
113
|
assert tx["gasPrice"] == 1 * GWEI
|
|
104
114
|
assert "maxFeePerGas" not in tx
|
|
105
115
|
assert "maxPriorityFeePerGas" not in tx
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_legacy_fallback_doubles_above_floor(mod):
|
|
119
|
+
# max_priority_fee unsupported → legacy path: gas_price*2 (60 gwei) > 1 gwei floor
|
|
120
|
+
w3 = _StubW3(gas_price=30 * GWEI, max_priority_fee=ValueError("no eip-1559"))
|
|
121
|
+
tx = {}
|
|
122
|
+
mod._set_gas_price_for(43114, w3, tx)
|
|
123
|
+
assert tx["gasPrice"] == 60 * GWEI
|
|
124
|
+
assert "maxFeePerGas" not in tx
|
|
125
|
+
assert "maxPriorityFeePerGas" not in tx
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_preset_eip1559_fields_left_untouched(mod):
|
|
129
|
+
# build_transaction already set the fee fields → helper must not override them,
|
|
130
|
+
# but must still drop a stale legacy gasPrice
|
|
131
|
+
w3 = _StubW3(gas_price=30 * GWEI, max_priority_fee=1 * GWEI)
|
|
132
|
+
tx = {"maxFeePerGas": 5 * GWEI, "maxPriorityFeePerGas": 2 * GWEI, "gasPrice": 1}
|
|
133
|
+
mod._set_gas_price_for(43114, w3, tx)
|
|
134
|
+
assert tx["maxFeePerGas"] == 5 * GWEI
|
|
135
|
+
assert tx["maxPriorityFeePerGas"] == 2 * GWEI
|
|
136
|
+
assert "gasPrice" not in tx
|
|
@@ -57,8 +57,8 @@ def test_compute_health_lever_branch_high_pct():
|
|
|
57
57
|
max_mult=10,
|
|
58
58
|
)
|
|
59
59
|
assert h["equity"] == 2000
|
|
60
|
-
assert h["
|
|
61
|
-
assert h["
|
|
60
|
+
assert h["health_pct"] == 85.0
|
|
61
|
+
assert h["health_pct"] > 70 # lever territory
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def test_compute_health_in_range_branch():
|
|
@@ -71,8 +71,8 @@ def test_compute_health_in_range_branch():
|
|
|
71
71
|
max_mult=10,
|
|
72
72
|
)
|
|
73
73
|
assert h["equity"] == 2000
|
|
74
|
-
assert h["
|
|
75
|
-
assert 30 <= h["
|
|
74
|
+
assert h["health_pct"] == 50.0
|
|
75
|
+
assert 30 <= h["health_pct"] <= 70
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def test_compute_health_delever_branch_low_pct():
|
|
@@ -85,8 +85,8 @@ def test_compute_health_delever_branch_low_pct():
|
|
|
85
85
|
max_mult=10,
|
|
86
86
|
)
|
|
87
87
|
assert h["equity"] == 2000
|
|
88
|
-
assert h["
|
|
89
|
-
assert h["
|
|
88
|
+
assert h["health_pct"] == 10.0
|
|
89
|
+
assert h["health_pct"] < 30 # de-lever territory
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def test_compute_health_equity_near_zero_errors():
|
|
@@ -99,7 +99,7 @@ def test_compute_health_equity_near_zero_errors():
|
|
|
99
99
|
max_mult=10,
|
|
100
100
|
)
|
|
101
101
|
assert h["error"] == "equity near zero"
|
|
102
|
-
assert h["
|
|
102
|
+
assert h["health_pct"] == 0.0
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def test_compute_health_basic_tier_lower_ceiling():
|
|
@@ -112,7 +112,7 @@ def test_compute_health_basic_tier_lower_ceiling():
|
|
|
112
112
|
assert basic["equity"] == premium["equity"] == 2000
|
|
113
113
|
assert basic["max_debt"] == 2000 * 4
|
|
114
114
|
assert premium["max_debt"] == 2000 * 9
|
|
115
|
-
assert basic["
|
|
115
|
+
assert basic["health_pct"] < premium["health_pct"]
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def test_compute_health_flat_format_and_position_detection():
|
|
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
|