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.
Files changed (22) hide show
  1. {primecli-0.5.5 → primecli-0.5.7}/PKG-INFO +2 -2
  2. {primecli-0.5.5 → primecli-0.5.7}/README.md +1 -1
  3. {primecli-0.5.5 → primecli-0.5.7}/primecli/arbprime.py +104 -38
  4. {primecli-0.5.5 → primecli-0.5.7}/primecli/degenprime.py +135 -28
  5. {primecli-0.5.5 → primecli-0.5.7}/primecli/deltaprime.py +83 -26
  6. {primecli-0.5.5 → primecli-0.5.7}/primecli/health_monitor.py +125 -65
  7. {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/PKG-INFO +2 -2
  8. {primecli-0.5.5 → primecli-0.5.7}/pyproject.toml +1 -1
  9. {primecli-0.5.5 → primecli-0.5.7}/tests/test_gas_pricing.py +46 -15
  10. {primecli-0.5.5 → primecli-0.5.7}/tests/test_health_monitor.py +8 -8
  11. {primecli-0.5.5 → primecli-0.5.7}/LICENSE +0 -0
  12. {primecli-0.5.5 → primecli-0.5.7}/primecli/__init__.py +0 -0
  13. {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/SOURCES.txt +0 -0
  14. {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/dependency_links.txt +0 -0
  15. {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/entry_points.txt +0 -0
  16. {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/requires.txt +0 -0
  17. {primecli-0.5.5 → primecli-0.5.7}/primecli.egg-info/top_level.txt +0 -0
  18. {primecli-0.5.5 → primecli-0.5.7}/setup.cfg +0 -0
  19. {primecli-0.5.5 → primecli-0.5.7}/tests/test_cross_file_identity.py +0 -0
  20. {primecli-0.5.5 → primecli-0.5.7}/tests/test_paraswap_validator.py +0 -0
  21. {primecli-0.5.5 → primecli-0.5.7}/tests/test_redstone_encoding.py +0 -0
  22. {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.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.2 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.5.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.2 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
19
+ **Current version:** 0.5.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
- # Must match the ParaSwap executor whitelist on DeltaPrime's ParaSwapFacet and
298
- # SwapDebtFacet. The ParaSwap API can return new executors that aren't whitelisted
299
- # yet those cause on-chain InvalidExecutor() reverts. Only add executors verified
300
- # to be whitelisted on-chain.
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 + maxPriorityFeePerGas with a 2x
675
- base-fee hedge (base + prio + 1 gwei buffer). On Avalanche (legacy): sets gasPrice at
676
- 2x base fee with a 1 gwei floor. (25 gwei was the pre-Etna C-chain minimum;
677
- ACP-125 (Dec 2024) lowered the min base fee to 1 nAVAX — base now sits at ~0.01
678
- nAVAX, so a 25 gwei floor overpaid ~2500x and inflated the upfront balance
679
- requirement past small EOAs.)"""
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
- if CHAIN_ID in (42161, 8453): # Arbitrum, Base — EIP-1559
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
- else: # Avalanche (43114) — legacy gasPrice
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. Arbitrum/Base (EIP-1559): maxFeePerGas + maxPriorityFeePerGas;
693
- Avalanche (legacy): gasPrice with a 1 gwei floor (post-Etna; see _set_gas_price)."""
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
- if chain_id in (42161, 8453): # Arbitrum, Base — EIP-1559
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
- else: # Avalanche (43114) — legacy gasPrice
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 executor-patching as swap-debt (see cmd_swap_debt for full rationale).
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
- if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
2332
- fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
2333
- data_bytes = fallback_bytes + data_bytes[32:]
2334
- print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
2335
- _paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"], to_cfg["token"],
2336
- amount_in, pa_cs)
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
- feeds = prime_account_price_feeds(account)
2352
- for s in (from_sym, to_sym):
2353
- if s not in feeds:
2354
- feeds.append(s)
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
- Broken on-chain due to a protocol-level bug on the Velora/ParaSwap facet (as of 2026-05-30).
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
- # If the ParaSwap API returned a new executor not on the DeltaPrime whitelist, patch
2770
- # it to EXECUTOR_3 (0x00001003…A07380A) the only legacy executor whose calldata
2771
- # format is compatible with the current API's output (tested on-chain 2026-05-28).
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
- if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
2774
- fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
2775
- data_bytes = fallback_bytes + data_bytes[32:]
2776
- print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
2777
- _paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"], from_cfg["token"],
2778
- borrow_amount, pa_cs)
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 + maxPriorityFeePerGas with a 2x
246
- base-fee hedge (base + prio + 1 gwei buffer). On Avalanche (legacy): sets gasPrice at
247
- 2x base fee with a 1 gwei floor. (25 gwei was the pre-Etna C-chain minimum;
248
- ACP-125 (Dec 2024) lowered the min base fee to 1 nAVAX — base now sits at ~0.01
249
- nAVAX, so a 25 gwei floor overpaid ~2500x and inflated the upfront balance
250
- requirement past small EOAs.)"""
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
- if CHAIN_ID in (42161, 8453): # Arbitrum, Base — EIP-1559
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
- else: # Avalanche (43114) — legacy gasPrice
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. DEGENPRIME_PRIVATE_KEY env var
263
- 3. DEGENPRIME_KEY_FILE env var (path to a file containing the 0x key)
264
- 4. DELTAPRIME_PRIVATE_KEY / DELTAPRIME_KEY_FILE (same key, both chains)
265
- Raises with a clear message if none of the four are set."""
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
- if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
1987
- fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
1988
- data_bytes = fallback_bytes + data_bytes[32:]
1989
- print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
1990
- _paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"], to_cfg["token"],
1991
- amount_in, pa_cs)
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
- feeds = degen_account_price_feeds(account)
2007
- for s in (from_sym, to_sym):
2008
- if s in REDSTONE_AVAILABLE_FEEDS and s not in feeds:
2009
- feeds.append(s)
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
- if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
2116
- fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
2117
- data_bytes = fallback_bytes + data_bytes[32:]
2118
- print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
2119
- _paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"], from_cfg["token"],
2120
- borrow_amount, pa_cs)
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
- # Must match the ParaSwap executor whitelist on DeltaPrime's ParaSwapFacet and
303
- # SwapDebtFacet. The ParaSwap API can return new executors that aren't whitelisted
304
- # yet those cause on-chain InvalidExecutor() reverts. Only add executors verified
305
- # to be whitelisted on-chain.
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 executor-patching as swap-debt (see cmd_swap_debt for full rationale).
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
- if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
2358
- fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
2359
- data_bytes = fallback_bytes + data_bytes[32:]
2360
- print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
2361
- _paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"], to_cfg["token"],
2362
- amount_in, pa_cs)
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
- feeds = prime_account_price_feeds(account)
2378
- for s in (from_sym, to_sym):
2379
- if s not in feeds:
2380
- feeds.append(s)
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
- Broken on-chain due to a protocol-level bug on the Velora/ParaSwap facet (as of 2026-05-30).
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
- # If the ParaSwap API returned a new executor not on the DeltaPrime whitelist, patch
2796
- # it to EXECUTOR_3 (0x00001003…A07380A) the only legacy executor whose calldata
2797
- # format is compatible with the current API's output (tested on-chain 2026-05-28).
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
- if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
2800
- fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
2801
- data_bytes = fallback_bytes + data_bytes[32:]
2802
- print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
2803
- _paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"], from_cfg["token"],
2804
- borrow_amount, pa_cs)
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 equity-based health (0-100%) from defi --json data.
40
+ """Compute health (0-100%) using the frontend formula from DeltaPrime docs.
41
41
 
42
- PREFERS the precomputed ``health_pct`` from defi --json (which primecli >= 0.5.4
43
- includes), falling back to manual calculation when absent.
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
- Formula:
46
- equity = total_supplied_usd - total_debt_usd
47
- max_debt = equity * (tier - 1) # PREMIUM=10, BASIC=5
48
- health% = (max_debt - debt) / max_debt * 100
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
- This is DIFFERENT from getHealthRatio (the on-chain ratio where 1.0 = liquidation).
51
- Do NOT convert between the two. The ``health_ratio`` metric is the on-chain value
52
- (1.0=liquidation, >1.0=solvent). The ``health_pct`` is the equity-based frontend
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") or g.get("bruno_pct") # bruno_pct for backward compat
64
+ precomputed = g.get("health_pct")
66
65
  if precomputed is not None:
67
- # Early return: precomputed value exists, enrich with detail fields
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 = any("GM_" in sym for sym in symbols)
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": float(precomputed),
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, equity * (max_mult - 1)), 2),
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 = equity * (max_mult - 1)
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 = any("GM_" in sym for sym in symbols)
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 = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
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
- delta_debt = (max_debt * 0.5) - debt_usd # center target = 50%
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("premium", 10)
310
- tier = "premium"
311
+ max_mult = TIER_MAX.get("basic", 5)
312
+ tier = "basic"
311
313
  except Exception:
312
- max_mult = 10
313
- tier = "premium"
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
- write_escalation(state_dir, "equity-near-zero", {
320
- "reason": "equity_near_zero",
321
- "equity": health["equity"],
322
- "debt": health["debt_usd"],
323
- "health_pct": health["health_pct"],
324
- "label": label,
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["raw_usdc"]
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 GMX (default position type)
517
- if position_type == "gmx":
518
- try:
519
- r = subprocess.run(
520
- [sys.executable, tool_path, "gmx-deposit",
521
- "--market", market, "--amount", f"{borrow_amt:.2f}",
522
- "--side", side, "--fee-buffer", "1.5", "--execute"],
523
- capture_output=True, text=True, timeout=120,
524
- )
525
- if r.returncode == 0:
526
- cooldown_file.write_text(str(int(time.time())))
527
- result["action"] = f"borrowed ${borrow_amt:.2f} + GMX deposit"
528
- else:
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
- result["action"] = f"escalate (unsupported position: {position_type})"
536
- write_escalation(state_dir, "unsupported-position", {
537
- "reason": "lever_unsupported_position",
538
- "position": position_type,
539
- "borrow_amt": borrow_amt,
540
- "label": label,
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 = "premium" # default
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.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.2 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.5.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.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 an explicit chain id (used by cross-chain flows
4
- like prime-bridge), so it must pick the right fee model per chain:
5
- * Arbitrum (42161) / Base (8453): EIP-1559 maxFeePerGas + maxPriorityFeePerGas,
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
- * Avalanche (43114): legacy gasPrice with a 1 gwei floor (post-Etna), and NO EIP-1559 fields.
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.max_priority_fee = max_priority_fee
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) — legacy gasPrice with 1 gwei floor (post-Etna ACP-125)
94
+ # Avalanche (43114) — EIP-1559 post-Etna (ACP-125); legacy gasPrice only as fallback
86
95
 
87
96
 
88
- def test_avalanche_sets_legacy_gasprice_no_eip1559(mod):
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 tx["gasPrice"] == 60 * GWEI
94
- assert "maxFeePerGas" not in tx
95
- assert "maxPriorityFeePerGas" not in tx
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 test_avalanche_applies_1_gwei_floor(mod):
99
- # gas_price*2 (0.02 gwei, realistic post-Etna base) < 1 gwei floor → floor wins
100
- w3 = _StubW3(gas_price=GWEI // 100, max_priority_fee=1 * GWEI)
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["bruno_pct"] == 85.0
61
- assert h["bruno_pct"] > 70 # lever territory
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["bruno_pct"] == 50.0
75
- assert 30 <= h["bruno_pct"] <= 70
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["bruno_pct"] == 10.0
89
- assert h["bruno_pct"] < 30 # de-lever territory
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["bruno_pct"] == 0.0
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["bruno_pct"] < premium["bruno_pct"]
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