primecli 0.11.2__tar.gz → 0.11.4__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 (33) hide show
  1. {primecli-0.11.2 → primecli-0.11.4}/PKG-INFO +2 -2
  2. {primecli-0.11.2 → primecli-0.11.4}/README.md +1 -1
  3. {primecli-0.11.2 → primecli-0.11.4}/primecli/arbprime.py +40 -8
  4. {primecli-0.11.2 → primecli-0.11.4}/primecli/degenprime.py +235 -77
  5. {primecli-0.11.2 → primecli-0.11.4}/primecli/deltaprime.py +58 -13
  6. {primecli-0.11.2 → primecli-0.11.4}/primecli.egg-info/PKG-INFO +2 -2
  7. {primecli-0.11.2 → primecli-0.11.4}/pyproject.toml +1 -1
  8. {primecli-0.11.2 → primecli-0.11.4}/LICENSE +0 -0
  9. {primecli-0.11.2 → primecli-0.11.4}/primecli/__init__.py +0 -0
  10. {primecli-0.11.2 → primecli-0.11.4}/primecli/_flowledger.py +0 -0
  11. {primecli-0.11.2 → primecli-0.11.4}/primecli/_wallets.py +0 -0
  12. {primecli-0.11.2 → primecli-0.11.4}/primecli/bridge.py +0 -0
  13. {primecli-0.11.2 → primecli-0.11.4}/primecli/health_monitor.py +0 -0
  14. {primecli-0.11.2 → primecli-0.11.4}/primecli.egg-info/SOURCES.txt +0 -0
  15. {primecli-0.11.2 → primecli-0.11.4}/primecli.egg-info/dependency_links.txt +0 -0
  16. {primecli-0.11.2 → primecli-0.11.4}/primecli.egg-info/entry_points.txt +0 -0
  17. {primecli-0.11.2 → primecli-0.11.4}/primecli.egg-info/requires.txt +0 -0
  18. {primecli-0.11.2 → primecli-0.11.4}/primecli.egg-info/top_level.txt +0 -0
  19. {primecli-0.11.2 → primecli-0.11.4}/setup.cfg +0 -0
  20. {primecli-0.11.2 → primecli-0.11.4}/tests/test_aero_range_and_swap_fallback.py +0 -0
  21. {primecli-0.11.2 → primecli-0.11.4}/tests/test_aero_rebalance.py +0 -0
  22. {primecli-0.11.2 → primecli-0.11.4}/tests/test_aero_v3_collision_fixes.py +0 -0
  23. {primecli-0.11.2 → primecli-0.11.4}/tests/test_bridge.py +0 -0
  24. {primecli-0.11.2 → primecli-0.11.4}/tests/test_cross_file_identity.py +0 -0
  25. {primecli-0.11.2 → primecli-0.11.4}/tests/test_flowledger_transferred_amount.py +0 -0
  26. {primecli-0.11.2 → primecli-0.11.4}/tests/test_gas_limit.py +0 -0
  27. {primecli-0.11.2 → primecli-0.11.4}/tests/test_gas_pricing.py +0 -0
  28. {primecli-0.11.2 → primecli-0.11.4}/tests/test_health_meter.py +0 -0
  29. {primecli-0.11.2 → primecli-0.11.4}/tests/test_health_monitor.py +0 -0
  30. {primecli-0.11.2 → primecli-0.11.4}/tests/test_paraswap_requote.py +0 -0
  31. {primecli-0.11.2 → primecli-0.11.4}/tests/test_paraswap_validator.py +0 -0
  32. {primecli-0.11.2 → primecli-0.11.4}/tests/test_redstone_encoding.py +0 -0
  33. {primecli-0.11.2 → primecli-0.11.4}/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.11.2
3
+ Version: 0.11.4
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.11.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.11.4 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.11.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.11.4 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
 
@@ -3470,12 +3470,24 @@ def cmd_withdrawal_intents():
3470
3470
  return
3471
3471
 
3472
3472
  any_pending = False
3473
+ # Batch the per-asset getAvailableBalance + getTotalIntentAmount + getUserIntents reads
3474
+ # (was 3 sequential eth_calls per owned asset) into one Multicall3 round-trip.
3475
+ pa_cs = account.address
3476
+ legs = []
3473
3477
  for a in owned:
3478
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getAvailableBalance", args=[a])[2:])))
3479
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getTotalIntentAmount", args=[a])[2:])))
3480
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getUserIntents", args=[a])[2:])))
3481
+ results = multicall(w3, legs)
3482
+ for i, a in enumerate(owned):
3474
3483
  sym = a.rstrip(b"\x00").decode(errors="replace")
3475
3484
  dec = _asset_decimals(sym)
3476
- available = account.functions.getAvailableBalance(a).call()
3477
- total_intent = account.functions.getTotalIntentAmount(a).call()
3478
- intents = account.functions.getUserIntents(a).call()
3485
+ av_ok, av_rd = results[3 * i]
3486
+ ti_ok, ti_rd = results[3 * i + 1]
3487
+ ui_ok, ui_rd = results[3 * i + 2]
3488
+ available = w3.codec.decode(["uint256"], av_rd)[0] if av_ok and av_rd else 0
3489
+ total_intent = w3.codec.decode(["uint256"], ti_rd)[0] if ti_ok and ti_rd else 0
3490
+ intents = w3.codec.decode(["(uint256,uint256,uint256,bool,bool,bool)[]"], ui_rd)[0] if ui_ok and ui_rd else []
3479
3491
  print(f" {sym}: available {available / 10**dec:,.6f}, "
3480
3492
  f"pending intents {total_intent / 10**dec:,.6f}")
3481
3493
  for idx, (amt, actionable_at, expires_at, is_pending, is_actionable, is_expired) in enumerate(intents):
@@ -4823,13 +4835,33 @@ def cmd_lb_remove(pair_key: str, slippage_pct: float = 1.0, execute: bool = Fals
4823
4835
  return
4824
4836
 
4825
4837
  pair_c = _lb_pair_contract(w3, pair_cs)
4826
- active_id = pair_c.functions.getActiveId().call()
4838
+ # Batch getActiveId + per-bin (balanceOf + totalSupply + getBin) into one Multicall3
4839
+ # round-trip (was 1 + 3N sequential eth_calls). This is a WRITE path — the decoded
4840
+ # amounts feed removeLiquidity and the slippage floors — so a failed read aborts rather
4841
+ # than silently defaulting to 0 (matches the prior abort-on-error behaviour).
4842
+ legs = [(pair_cs, bytes.fromhex(pair_c.encode_abi("getActiveId", args=[])[2:]))]
4843
+ for binid in ids:
4844
+ legs.append((pair_cs, bytes.fromhex(pair_c.encode_abi("balanceOf", args=[pa_cs, binid])[2:])))
4845
+ legs.append((pair_cs, bytes.fromhex(pair_c.encode_abi("totalSupply", args=[binid])[2:])))
4846
+ legs.append((pair_cs, bytes.fromhex(pair_c.encode_abi("getBin", args=[binid])[2:])))
4847
+ results = multicall(w3, legs)
4848
+ a_ok, a_rd = results[0]
4849
+ if not (a_ok and a_rd):
4850
+ print(" Could not read the LB pair's active bin — refusing to remove.")
4851
+ return
4852
+ active_id = w3.codec.decode(["uint24"], a_rd)[0]
4827
4853
  amounts = []
4828
4854
  est_x = est_y = 0.0
4829
- for binid in ids:
4830
- bal = pair_c.functions.balanceOf(pa_cs, binid).call()
4831
- ts = pair_c.functions.totalSupply(binid).call()
4832
- rx, ry = pair_c.functions.getBin(binid).call()
4855
+ for k, binid in enumerate(ids):
4856
+ b_ok, b_rd = results[1 + 3 * k]
4857
+ t_ok, t_rd = results[1 + 3 * k + 1]
4858
+ g_ok, g_rd = results[1 + 3 * k + 2]
4859
+ if not (b_ok and b_rd and t_ok and t_rd and g_ok and g_rd):
4860
+ print(f" Could not read bin {binid} on [{pair_key}] — refusing to remove (incomplete data).")
4861
+ return
4862
+ bal = w3.codec.decode(["uint256"], b_rd)[0]
4863
+ ts = w3.codec.decode(["uint256"], t_rd)[0]
4864
+ rx, ry = w3.codec.decode(["uint128", "uint128"], g_rd)
4833
4865
  amounts.append(bal)
4834
4866
  share = (bal / ts) if ts else 0
4835
4867
  est_x += rx * share / 10**x_cfg["decimals"]
@@ -1492,12 +1492,21 @@ def build_redstone_payload(symbols: list) -> bytes:
1492
1492
  payload += REDSTONE_MARKER
1493
1493
  return payload
1494
1494
 
1495
- def degen_account_price_feeds(account) -> list:
1495
+ def degen_account_price_feeds(account, w3=None) -> list:
1496
1496
  """RedStone feed symbols a solvency check on this account needs: ETH (Base's native
1497
1497
  base-asset reference, the BaseOracle anchor), every owned asset that has a RedStone
1498
1498
  feed, and every debt-registry asset that has one. Symbols without a RedStone feed
1499
1499
  are skipped - the SolvencyFacet sources them on-chain from BaseOracle TWAP and the
1500
- payload doesn't need to cover them. Deduped, ETH first."""
1500
+ payload doesn't need to cover them. Deduped, ETH first.
1501
+
1502
+ Staked Aerodrome LP legs need the same treatment: getAllOwnedAssets() and
1503
+ getDebts() never see tokens locked inside a staked LP NFT (per
1504
+ _aero_position_legs's own docstring), so an account whose ONLY exposure to a
1505
+ RedStone-priced asset is via a staked LP (no raw balance, no debt in it) would
1506
+ otherwise never request that feed - and every solvency-gated call
1507
+ (getTotalValue/getHealthRatio/isSolvent/repay/shouldRebalance) reverts for lack
1508
+ of a price instead of pricing the position correctly. Pass w3 to also enumerate
1509
+ those legs; omit it to keep the old (narrower) behavior."""
1501
1510
  feeds = ["ETH"]
1502
1511
  for a in account.functions.getAllOwnedAssets().call():
1503
1512
  sym = a.rstrip(b"\x00").decode(errors="replace")
@@ -1507,6 +1516,18 @@ def degen_account_price_feeds(account) -> list:
1507
1516
  sym = name.rstrip(b"\x00").decode(errors="replace")
1508
1517
  if sym and sym in REDSTONE_AVAILABLE_FEEDS:
1509
1518
  feeds.append(sym)
1519
+ if w3 is not None:
1520
+ for lg in _aero_position_legs(w3, account):
1521
+ for raw_sym in (lg.get("sym0"), lg.get("sym1")):
1522
+ if not raw_sym:
1523
+ continue
1524
+ # LP legs are read straight off the ERC20 (_resolve_token_symbol),
1525
+ # which returns the real on-chain symbol (e.g. "EURC"). DegenPrime's
1526
+ # TokenManager - and RedStone's feed - use the aliased name ("EUROC"),
1527
+ # same alias _account_asset_symbol already applies for raw holdings.
1528
+ sym = _account_asset_symbol(raw_sym)
1529
+ if sym in REDSTONE_AVAILABLE_FEEDS:
1530
+ feeds.append(sym)
1510
1531
  return list(dict.fromkeys(feeds))
1511
1532
 
1512
1533
  def redstone_view_call(w3, account, fn_name: str, payload: bytes, args: list = None):
@@ -2221,7 +2242,7 @@ def _gather_account_state(w3, account, pool_deposits: list):
2221
2242
  # but correct: the SolvencyFacet parses the payload from the calldata tail per leg.
2222
2243
  solvency = {"total": None, "debt": None, "ratio": None, "solvent": None, "error": None, "prices": {}}
2223
2244
  try:
2224
- feeds = degen_account_price_feeds(account)
2245
+ feeds = degen_account_price_feeds(account, w3=w3)
2225
2246
  # Pool-deposit assets need their feeds in the payload too, else getPrices reverts
2226
2247
  # on any deposit symbol whose feed the Degen Account doesn't already carry.
2227
2248
  for _dr in pool_deposits:
@@ -2229,8 +2250,12 @@ def _gather_account_state(w3, account, pool_deposits: list):
2229
2250
  feeds.append(_dr["symbol"])
2230
2251
  payload = build_redstone_payload(feeds)
2231
2252
  payload_hex = payload.hex()
2232
- price_syms = [s for s in dict.fromkeys(r["symbol"] for r in supplied + borrowed + pool_deposits)
2233
- if s and s in REDSTONE_AVAILABLE_FEEDS]
2253
+ # Reuse `feeds` (already ETH + owned + debts + pool_deposits + staked LP legs,
2254
+ # alias-corrected, filtered to REDSTONE_AVAILABLE_FEEDS) instead of re-deriving
2255
+ # from supplied/borrowed/pool_deposits alone - that narrower set is exactly what
2256
+ # left LP-locked-only assets (e.g. EURC/EUROC) unpriced for the display/health
2257
+ # computation below even after the solvency payload itself carried the feed.
2258
+ price_syms = list(dict.fromkeys(feeds))
2234
2259
  solv_legs = [
2235
2260
  ("getTotalValue", ["uint256"], account.encode_abi("getTotalValue", args=[])),
2236
2261
  ("getDebt", ["uint256"], account.encode_abi("getDebt", args=[])),
@@ -2275,6 +2300,56 @@ def _gather_account_state(w3, account, pool_deposits: list):
2275
2300
  return pa_eth, supplied, borrowed, solvency
2276
2301
 
2277
2302
 
2303
+ def _aero_resolve_positions_batched(w3, token_ids, pa):
2304
+ """Resolve {tokenId: (npm_contract, deployment, positions_struct)} for a list of staked
2305
+ Aerodrome tokenIds, batching the positions() probes on the V2 and V3 NPMs into ONE
2306
+ Multicall3 round-trip (previously 2 sequential eth_calls per id inside
2307
+ _aero_npm_for_token). aggregate3 allowFailure=True mirrors that resolver's try/except:
2308
+ a positions() revert means the id is not live on that deployment. For an id live on BOTH
2309
+ deployments (overlapping id ranges) the security-sensitive "which deployment does pa
2310
+ actually own" disambiguation is delegated unchanged to per-id _aero_npm_for_token. Dead
2311
+ ids are omitted. Each (contract, deployment, struct) returned is identical to what
2312
+ _aero_npm_for_token(w3, id, pa) would return."""
2313
+ if not token_ids:
2314
+ return {}
2315
+ npms = {
2316
+ "v2": w3.eth.contract(address=Web3.to_checksum_address(AERODROME_NPM_V2), abi=AERODROME_NPM_ABI),
2317
+ "v3": w3.eth.contract(address=Web3.to_checksum_address(AERODROME_NPM_V3), abi=AERODROME_NPM_ABI),
2318
+ }
2319
+ pos_out_types = [o["type"] for o in next(
2320
+ f for f in AERODROME_NPM_ABI if f.get("name") == "positions")["outputs"]]
2321
+ order = ("v2", "v3")
2322
+ legs = []
2323
+ for tid in token_ids:
2324
+ for ver in order:
2325
+ legs.append((npms[ver].address,
2326
+ bytes.fromhex(npms[ver].encode_abi("positions", args=[tid])[2:])))
2327
+ try:
2328
+ results = multicall(w3, legs)
2329
+ except Exception:
2330
+ results = [(False, b"")] * len(legs)
2331
+ resolved = {}
2332
+ for i, tid in enumerate(token_ids):
2333
+ live = []
2334
+ for j, ver in enumerate(order):
2335
+ ok, rd = results[2 * i + j]
2336
+ if ok and rd:
2337
+ try:
2338
+ live.append((ver, w3.codec.decode(pos_out_types, rd)))
2339
+ except Exception:
2340
+ continue
2341
+ if not live:
2342
+ continue
2343
+ if len(live) > 1 and pa is not None:
2344
+ npm, ver, pos = _aero_npm_for_token(w3, tid, pa)
2345
+ if npm is not None:
2346
+ resolved[tid] = (npm, ver, pos)
2347
+ else:
2348
+ ver, pos = live[0]
2349
+ resolved[tid] = (npms[ver], ver, pos)
2350
+ return resolved
2351
+
2352
+
2278
2353
  def _aero_gauge_earned(w3, degen_account: 'Web3.eth.Contract', token_id: int) -> float:
2279
2354
  """Query unclaimed AERO rewards from the Aerodrome gauge for a staked LP.
2280
2355
 
@@ -2331,11 +2406,16 @@ def _aero_position_legs(w3, account):
2331
2406
  return []
2332
2407
  slot0_abi = json.loads(SLOT0_ABI)
2333
2408
  q96 = Decimal(2) ** 96
2409
+ # Resolve V2/V3 per tokenId in ONE batched positions() round-trip (was 2 sequential
2410
+ # eth_calls per id). _aero_resolve_positions_batched returns exactly what per-id
2411
+ # _aero_npm_for_token would, ownership disambiguation for both-live ids included.
2412
+ resolved = _aero_resolve_positions_batched(w3, ids, account.address)
2334
2413
  legs = []
2335
2414
  for tid in ids:
2336
- # Resolve V2/V3 per tokenId — the two NPMs are independent ERC-721s. Pass the
2337
- # prime account so an id live on both deployments resolves to the one it owns.
2338
- _npm, ver, p = _aero_npm_for_token(w3, tid, account.address)
2415
+ entry = resolved.get(tid)
2416
+ if entry is None:
2417
+ continue
2418
+ _npm, ver, p = entry
2339
2419
  if p is None:
2340
2420
  continue
2341
2421
  # positions(): nonce, operator, token0, token1, tickSpacing, tickLower,
@@ -2348,8 +2428,12 @@ def _aero_position_legs(w3, account):
2348
2428
  dec1 = _resolve_token_decimals(w3, token1)
2349
2429
  if dec0 is None or dec1 is None:
2350
2430
  continue
2351
- sym0 = _resolve_token_symbol(w3, token0)
2352
- sym1 = _resolve_token_symbol(w3, token1)
2431
+ # Aliased (_account_asset_symbol) so a leg's symbol matches how DegenPrime's
2432
+ # TokenManager / RedStone feeds name it (e.g. "EURC" on-chain -> "EUROC"),
2433
+ # not the raw ERC20 symbol - callers price these legs against REDSTONE_
2434
+ # AVAILABLE_FEEDS / solvency["prices"], both keyed by the aliased name.
2435
+ sym0 = _account_asset_symbol(_resolve_token_symbol(w3, token0))
2436
+ sym1 = _account_asset_symbol(_resolve_token_symbol(w3, token1))
2353
2437
  # Current sqrtPriceX96 from the pool's slot0 (exact); fall back to the
2354
2438
  # geometric mean of the range bounds if the pool read fails.
2355
2439
  sqrt_p = None
@@ -2817,9 +2901,38 @@ def _aero_unclaimed_usd(w3, account, solvency_prices: dict) -> float:
2817
2901
  token_ids = []
2818
2902
  if not token_ids:
2819
2903
  return 0.0
2904
+ # Batch the gauge-reward reads: resolve each staked NFT's NPM (one batched positions()
2905
+ # round-trip), then batch ownerOf() to find each gauge, then batch earned() across all
2906
+ # gauges — was ~4 sequential eth_calls per id via _aero_gauge_earned. Fail-closed per
2907
+ # position exactly like _aero_gauge_earned: any failed leg contributes 0 AERO.
2820
2908
  total_aero = 0.0
2821
- for tid in token_ids:
2822
- total_aero += _aero_gauge_earned(w3, account, tid)
2909
+ resolved = _aero_resolve_positions_batched(w3, token_ids, account.address)
2910
+ staked = [(tid, resolved[tid][0]) for tid in token_ids if tid in resolved]
2911
+ if staked:
2912
+ owner_legs = [(npm.address, bytes.fromhex(npm.encode_abi("ownerOf", args=[tid])[2:]))
2913
+ for tid, npm in staked]
2914
+ try:
2915
+ owner_res = multicall(w3, owner_legs)
2916
+ except Exception:
2917
+ owner_res = [(False, b"")] * len(owner_legs)
2918
+ _gauge_abi = json.loads('[{"inputs":[{"name":"","type":"address"},{"name":"","type":"uint256"}],"name":"earned","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]')
2919
+ earned_legs = []
2920
+ for (tid, _npm), (ok, rd) in zip(staked, owner_res):
2921
+ if not (ok and rd):
2922
+ continue
2923
+ gauge_addr = w3.codec.decode(["address"], rd)[0]
2924
+ if not gauge_addr or int(gauge_addr, 16) == 0:
2925
+ continue
2926
+ gc = w3.eth.contract(address=Web3.to_checksum_address(gauge_addr), abi=_gauge_abi)
2927
+ earned_legs.append((Web3.to_checksum_address(gauge_addr),
2928
+ bytes.fromhex(gc.encode_abi("earned", args=[account.address, tid])[2:])))
2929
+ try:
2930
+ earned_res = multicall(w3, earned_legs)
2931
+ except Exception:
2932
+ earned_res = [(False, b"")] * len(earned_legs)
2933
+ for ok, rd in earned_res:
2934
+ if ok and rd:
2935
+ total_aero += w3.codec.decode(["uint256"], rd)[0] / 10 ** 18
2823
2936
  if total_aero <= 0:
2824
2937
  return 0.0
2825
2938
  aero_price = (solvency_prices or {}).get("AERO")
@@ -3060,7 +3173,7 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
3060
3173
  print(f" Capped requested {amount} {symbol} to {amount_wei / 10**cfg['decimals']:.6f} "
3061
3174
  f"({'; '.join(cap_notes)}).")
3062
3175
  # repay's internal _isSolvent uses proxyDelegateCalldata -> needs RedStone payload
3063
- feeds = degen_account_price_feeds(account)
3176
+ feeds = degen_account_price_feeds(account, w3=w3)
3064
3177
  if symbol not in feeds and symbol in REDSTONE_AVAILABLE_FEEDS:
3065
3178
  feeds.append(symbol)
3066
3179
  payload = build_redstone_payload(feeds)
@@ -3285,6 +3398,43 @@ def _paraswap_requote_until_clean(src_token, src_dec, dest_token, dest_dec, amou
3285
3398
  print(f" ✗ All {_PARASWAP_REQUOTE_ATTEMPTS} ParaSwap quotes reverted in simulation.")
3286
3399
  return last
3287
3400
 
3401
+ # Bounded slippage escalation: re-quoting at a FIXED slippage_pct only helps when
3402
+ # ParaSwap rotates to a different (whitelisted) executor/route. It does nothing when
3403
+ # ParaSwap's own off-chain quote is itself measurably rich vs the pool's live price -
3404
+ # confirmed on a WETH/EURC Aerodrome Slipstream pool where the quote sat ~3.2% above
3405
+ # the pool's own slot0 price, so every requote at 1% slippage reverted identically with
3406
+ # SwapFailed() (0x81ceff30, the facet's wrapper around ParaSwap's own
3407
+ # InsufficientReturnAmount()). Widening slippage (not re-quoting) is what actually
3408
+ # clears that case. Steps up only on that exact failure signature - a different error
3409
+ # (e.g. insufficient balance) won't be fixed by more slippage room, so we stop
3410
+ # immediately rather than burning extra RPC round-trips.
3411
+ _SLIPPAGE_ESCALATION_STEP_PCT = 1.5
3412
+ _SLIPPAGE_ESCALATION_MAX_PCT = 4.5 # stays clear of the facet's hard 5% ceiling
3413
+
3414
+ def _paraswap_swap_with_escalation(src_token, src_dec, dest_token, dest_dec, amount_in_wei,
3415
+ slippage_pct, pa_cs, sim_fn):
3416
+ """Wraps _paraswap_requote_until_clean with bounded slippage escalation (see above).
3417
+ Starts at the caller's slippage_pct (unchanged behavior if that already clears).
3418
+ On an all-attempts-failed SwapFailed() result, steps slippage_pct up by
3419
+ _SLIPPAGE_ESCALATION_STEP_PCT and re-runs the full requote loop, capped at
3420
+ _SLIPPAGE_ESCALATION_MAX_PCT. Returns the same shape as
3421
+ _paraswap_requote_until_clean (the last attempted route, sim_ok reflecting whether
3422
+ it ultimately cleared)."""
3423
+ slip = slippage_pct
3424
+ while True:
3425
+ route = _paraswap_requote_until_clean(src_token, src_dec, dest_token, dest_dec,
3426
+ amount_in_wei, slip, pa_cs, sim_fn)
3427
+ route["slippage_pct_used"] = slip
3428
+ if route["sim_ok"] or slip >= _SLIPPAGE_ESCALATION_MAX_PCT:
3429
+ return route
3430
+ if not route["last_err"] or "81ceff30" not in str(route["last_err"]):
3431
+ return route # different failure mode - more slippage room won't fix it
3432
+ next_slip = min(slip + _SLIPPAGE_ESCALATION_STEP_PCT, _SLIPPAGE_ESCALATION_MAX_PCT)
3433
+ print(f" All quotes failed with SwapFailed() at {slip:.2f}% slippage - the "
3434
+ f"ParaSwap quote may be stale vs the live pool price. Retrying at "
3435
+ f"{next_slip:.2f}%...")
3436
+ slip = next_slip
3437
+
3288
3438
  def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.0,
3289
3439
  execute: bool = False):
3290
3440
  """Swap one in-account asset for another via the Degen Account on ParaSwap v6.
@@ -3351,19 +3501,22 @@ def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.
3351
3501
  return True, None
3352
3502
  except Exception as e:
3353
3503
  return False, str(e)
3354
- route = _paraswap_requote_until_clean(
3504
+ route = _paraswap_swap_with_escalation(
3355
3505
  from_cfg["token"], from_cfg["decimals"], to_cfg["token"], to_cfg["decimals"],
3356
3506
  amount_in, slippage_pct, pa_cs, _sim_paraswap)
3357
3507
  price_route, tx_built, full = route["price_route"], route["tx_built"], route["full"]
3358
3508
  selector_hex, data_bytes = route["selector_hex"], route["data_bytes"]
3359
3509
  quoted_out, min_out, sim_ok = route["quoted_out"], route["min_out"], route["sim_ok"]
3510
+ slippage_used = route.get("slippage_pct_used", slippage_pct)
3360
3511
 
3361
3512
  print(f"Swap {amount} {from_asset_sym} -> {to_asset_sym} on Degen Account {pa_cs} (via ParaSwap/Velora)")
3362
3513
  print(f" Router method: {price_route['contractMethod']} ({selector_hex})")
3363
3514
  print(f" Augustus router: {tx_built['to']}")
3364
3515
  print(f" Expected out: {quoted_out / 10**to_cfg['decimals']:.6f} {to_asset_sym}")
3365
3516
  if min_out is not None:
3366
- print(f" Min out (@{slippage_pct}% slippage): {min_out / 10**to_cfg['decimals']:.6f} {to_asset_sym}")
3517
+ print(f" Min out (@{slippage_used:.2f}% slippage): {min_out / 10**to_cfg['decimals']:.6f} {to_asset_sym}")
3518
+ if slippage_used != slippage_pct:
3519
+ print(f" (requested {slippage_pct}% was not routable - escalated to {slippage_used:.2f}% to clear SwapFailed())")
3367
3520
  print(f" ParaSwap srcUSD ${price_route.get('srcUSD','?')} -> destUSD ${price_route.get('destUSD','?')}")
3368
3521
  print(" Facet enforces a 5% hard slippage cap (RedStone-priced) on top of this.")
3369
3522
 
@@ -3372,8 +3525,9 @@ def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.
3372
3525
  return
3373
3526
 
3374
3527
  if not sim_ok:
3375
- print(f"✗ Refusing to broadcast: every ParaSwap quote reverted in simulation "
3376
- f"({_PARASWAP_REQUOTE_ATTEMPTS} attempts). Try again shortly.")
3528
+ print(f"✗ Refusing to broadcast: every ParaSwap quote reverted in simulation, "
3529
+ f"even after escalating slippage tolerance up to {slippage_used:.2f}%. "
3530
+ "Try again shortly.")
3377
3531
  return
3378
3532
 
3379
3533
  # Rebuild the payload fresh for broadcast (the sim payload may be near the
@@ -3636,12 +3790,24 @@ def cmd_withdrawal_intents():
3636
3790
  return
3637
3791
 
3638
3792
  any_pending = False
3793
+ # Batch the per-asset getAvailableBalance + getTotalIntentAmount + getUserIntents reads
3794
+ # (was 3 sequential eth_calls per owned asset) into one Multicall3 round-trip.
3795
+ pa_cs = account.address
3796
+ legs = []
3639
3797
  for a in owned:
3798
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getAvailableBalance", args=[a])[2:])))
3799
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getTotalIntentAmount", args=[a])[2:])))
3800
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getUserIntents", args=[a])[2:])))
3801
+ results = multicall(w3, legs)
3802
+ for i, a in enumerate(owned):
3640
3803
  sym = a.rstrip(b"\x00").decode(errors="replace")
3641
3804
  dec = _asset_decimals(w3, sym)
3642
- available = account.functions.getAvailableBalance(a).call()
3643
- total_intent = account.functions.getTotalIntentAmount(a).call()
3644
- intents = account.functions.getUserIntents(a).call()
3805
+ av_ok, av_rd = results[3 * i]
3806
+ ti_ok, ti_rd = results[3 * i + 1]
3807
+ ui_ok, ui_rd = results[3 * i + 2]
3808
+ available = w3.codec.decode(["uint256"], av_rd)[0] if av_ok and av_rd else 0
3809
+ total_intent = w3.codec.decode(["uint256"], ti_rd)[0] if ti_ok and ti_rd else 0
3810
+ intents = w3.codec.decode(["(uint256,uint256,uint256,bool,bool,bool)[]"], ui_rd)[0] if ui_ok and ui_rd else []
3645
3811
  print(f" {sym}: available {available / 10**dec:,.6f}, "
3646
3812
  f"pending intents {total_intent / 10**dec:,.6f}")
3647
3813
  for idx, (amt, actionable_at, expires_at, is_pending, is_actionable, is_expired) in enumerate(intents):
@@ -4369,6 +4535,48 @@ def _aero_separate_pool_and_sweeps(valuable: dict, sym0: str, sym1: str):
4369
4535
  return pool0_bal_wei, pool1_bal_wei, sweeps
4370
4536
 
4371
4537
 
4538
+ def _aero_inventory_available(w3, account, pool_cfg):
4539
+ """Inventory every REDSTONE-fed asset + both pool tokens held in the Degen Account, net
4540
+ of locked (withdrawal-intent) amounts. Batches the per-symbol getBalance +
4541
+ getTotalIntentAmount reads into ONE Multicall3 round-trip (was 2 sequential eth_calls per
4542
+ symbol — the documented cause of Base-converge RPC 429s). aggregate3 allowFailure=True
4543
+ preserves the old per-call tolerance: a reverting/failed leg reads 0, exactly as the
4544
+ previous per-call try/except did. Returns {sym: [avail_wei, decimals, 0.0]} for symbols
4545
+ with a positive available balance and resolvable decimals (empty dict if none)."""
4546
+ sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
4547
+ candidates = sorted(set(REDSTONE_AVAILABLE_FEEDS) | {sym0, sym1})
4548
+ legs = []
4549
+ for sym in candidates:
4550
+ ab = asset_b32(_account_asset_symbol(sym))
4551
+ legs.append((account.address, bytes.fromhex(account.encode_abi("getBalance", args=[ab])[2:])))
4552
+ legs.append((account.address, bytes.fromhex(account.encode_abi("getTotalIntentAmount", args=[ab])[2:])))
4553
+ try:
4554
+ results = multicall(w3, legs)
4555
+ except Exception:
4556
+ results = [(False, b"")] * len(legs)
4557
+ inventory = {}
4558
+ for i, sym in enumerate(candidates):
4559
+ b_ok, b_rd = results[2 * i]
4560
+ l_ok, l_rd = results[2 * i + 1]
4561
+ bal = w3.codec.decode(["uint256"], b_rd)[0] if b_ok and b_rd else 0
4562
+ if bal <= 0:
4563
+ continue
4564
+ locked = w3.codec.decode(["uint256"], l_rd)[0] if l_ok and l_rd else 0
4565
+ avail = bal - locked if bal > locked else 0
4566
+ if avail <= 0:
4567
+ continue
4568
+ dec = pool_cfg["decimals0"] if sym == sym0 else (
4569
+ pool_cfg["decimals1"] if sym == sym1 else None)
4570
+ if dec is None:
4571
+ meta = _swap_asset_meta(w3, sym)
4572
+ if meta:
4573
+ dec = meta.get("decimals")
4574
+ if dec is None:
4575
+ continue
4576
+ inventory[sym] = [avail, dec, 0.0]
4577
+ return inventory
4578
+
4579
+
4372
4580
  def _aero_use_all_available(
4373
4581
  w3, acct, account, pa_cs, pool_cfg,
4374
4582
  width_pct=2.0, slippage_pct=1.0, execute=False,
@@ -4390,34 +4598,9 @@ def _aero_use_all_available(
4390
4598
  dec0, dec1 = pool_cfg["decimals0"], pool_cfg["decimals1"]
4391
4599
  MIN_USD_VALUE = 5.0
4392
4600
 
4393
- # 1. Inventory: check RedStone-fed assets + both pool tokens for balance
4394
- all_candidates = set(REDSTONE_AVAILABLE_FEEDS) | {sym0, sym1}
4395
- inventory = {}
4396
- for sym in sorted(all_candidates):
4397
- account_sym = _account_asset_symbol(sym)
4398
- try:
4399
- bal = account.functions.getBalance(asset_b32(account_sym)).call()
4400
- except Exception:
4401
- bal = 0
4402
- if bal <= 0:
4403
- continue
4404
- try:
4405
- locked = account.functions.getTotalIntentAmount(asset_b32(account_sym)).call()
4406
- except Exception:
4407
- locked = 0
4408
- avail = bal - locked if bal > locked else 0
4409
- if avail <= 0:
4410
- continue
4411
- dec = pool_cfg["decimals0"] if sym == sym0 else (
4412
- pool_cfg["decimals1"] if sym == sym1 else None)
4413
- if dec is None:
4414
- meta = _swap_asset_meta(w3, sym)
4415
- if meta:
4416
- dec = meta.get("decimals")
4417
- if dec is None:
4418
- continue
4419
- inventory[sym] = [avail, dec, 0.0]
4420
-
4601
+ # 1. Inventory: check RedStone-fed assets + both pool tokens for balance, batched into
4602
+ # one Multicall3 round-trip (see _aero_inventory_available).
4603
+ inventory = _aero_inventory_available(w3, account, pool_cfg)
4421
4604
  if not inventory:
4422
4605
  print(" No in-account assets found with non-zero balance.")
4423
4606
  return False
@@ -5761,7 +5944,7 @@ def cmd_aero_rebalance_status(token_id: int = None, check: bool = False,
5761
5944
  d["position"] = None
5762
5945
  if check:
5763
5946
  try:
5764
- payload = build_redstone_payload(degen_account_price_feeds(account))
5947
+ payload = build_redstone_payload(degen_account_price_feeds(account, w3=w3))
5765
5948
  d["shouldRebalance"] = bool(redstone_view_call(
5766
5949
  w3, account, "shouldRebalance", payload, args=[tid])[0])
5767
5950
  except Exception as e:
@@ -5926,34 +6109,9 @@ def _aero_rebuild_sweep(w3, acct, account, pa_cs, pool_key, pool_cfg=None, execu
5926
6109
  sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
5927
6110
  MIN_USD_VALUE = 5.0
5928
6111
 
5929
- # Inventory RedStone-priced assets
5930
- candidates = set(REDSTONE_AVAILABLE_FEEDS) | {sym0, sym1}
5931
- inventory = {}
5932
- for sym in sorted(candidates):
5933
- account_sym = _account_asset_symbol(sym)
5934
- try:
5935
- bal = account.functions.getBalance(asset_b32(account_sym)).call()
5936
- except Exception:
5937
- bal = 0
5938
- if bal <= 0:
5939
- continue
5940
- try:
5941
- locked = account.functions.getTotalIntentAmount(asset_b32(account_sym)).call()
5942
- except Exception:
5943
- locked = 0
5944
- avail = bal - locked if bal > locked else 0
5945
- if avail <= 0:
5946
- continue
5947
- dec = pool_cfg["decimals0"] if sym == sym0 else (
5948
- pool_cfg["decimals1"] if sym == sym1 else None)
5949
- if dec is None:
5950
- meta = _swap_asset_meta(w3, sym)
5951
- if meta:
5952
- dec = meta.get("decimals")
5953
- if dec is None:
5954
- continue
5955
- inventory[sym] = [avail, dec, 0.0]
5956
-
6112
+ # Inventory RedStone-priced assets + both pool tokens, batched into one Multicall3
6113
+ # round-trip (see _aero_inventory_available).
6114
+ inventory = _aero_inventory_available(w3, account, pool_cfg)
5957
6115
  if not inventory:
5958
6116
  return
5959
6117
 
@@ -1726,11 +1726,24 @@ def cmd_withdrawal_requests():
1726
1726
  acct = get_account()
1727
1727
  print(f"Wallet: {acct.address}")
1728
1728
  any_pending = False
1729
- for pool_name, cfg in POOLS.items():
1730
- contract = w3.eth.contract(address=Web3.to_checksum_address(cfg["proxy"]), abi=POOL_ABI)
1731
- balance = contract.functions.balanceOf(acct.address).call()
1732
- total_intent = contract.functions.getTotalIntentAmount(acct.address).call()
1733
- intents = contract.functions.getUserIntents(acct.address).call()
1729
+ # Batch the per-pool balanceOf + getTotalIntentAmount + getUserIntents reads (was 3
1730
+ # sequential eth_calls per pool) into one Multicall3 round-trip.
1731
+ pool_meta = list(POOLS.items())
1732
+ legs = []
1733
+ for _pool_name, cfg in pool_meta:
1734
+ proxy_cs = Web3.to_checksum_address(cfg["proxy"])
1735
+ contract = w3.eth.contract(address=proxy_cs, abi=POOL_ABI)
1736
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("balanceOf", args=[acct.address])[2:])))
1737
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("getTotalIntentAmount", args=[acct.address])[2:])))
1738
+ legs.append((proxy_cs, bytes.fromhex(contract.encode_abi("getUserIntents", args=[acct.address])[2:])))
1739
+ results = multicall(w3, legs)
1740
+ for i, (pool_name, cfg) in enumerate(pool_meta):
1741
+ b_ok, b_rd = results[3 * i]
1742
+ t_ok, t_rd = results[3 * i + 1]
1743
+ u_ok, u_rd = results[3 * i + 2]
1744
+ balance = w3.codec.decode(["uint256"], b_rd)[0] if b_ok and b_rd else 0
1745
+ total_intent = w3.codec.decode(["uint256"], t_rd)[0] if t_ok and t_rd else 0
1746
+ intents = w3.codec.decode(["(uint256,uint256,uint256,bool,bool,bool)[]"], u_rd)[0] if u_ok and u_rd else []
1734
1747
  if balance == 0 and not intents:
1735
1748
  continue
1736
1749
  dec = cfg["decimals"]
@@ -3492,12 +3505,24 @@ def cmd_withdrawal_intents():
3492
3505
  return
3493
3506
 
3494
3507
  any_pending = False
3508
+ # Batch the per-asset getAvailableBalance + getTotalIntentAmount + getUserIntents reads
3509
+ # (was 3 sequential eth_calls per owned asset) into one Multicall3 round-trip.
3510
+ pa_cs = account.address
3511
+ legs = []
3495
3512
  for a in owned:
3513
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getAvailableBalance", args=[a])[2:])))
3514
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getTotalIntentAmount", args=[a])[2:])))
3515
+ legs.append((pa_cs, bytes.fromhex(account.encode_abi("getUserIntents", args=[a])[2:])))
3516
+ results = multicall(w3, legs)
3517
+ for i, a in enumerate(owned):
3496
3518
  sym = a.rstrip(b"\x00").decode(errors="replace")
3497
3519
  dec = _asset_decimals(sym)
3498
- available = account.functions.getAvailableBalance(a).call()
3499
- total_intent = account.functions.getTotalIntentAmount(a).call()
3500
- intents = account.functions.getUserIntents(a).call()
3520
+ av_ok, av_rd = results[3 * i]
3521
+ ti_ok, ti_rd = results[3 * i + 1]
3522
+ ui_ok, ui_rd = results[3 * i + 2]
3523
+ available = w3.codec.decode(["uint256"], av_rd)[0] if av_ok and av_rd else 0
3524
+ total_intent = w3.codec.decode(["uint256"], ti_rd)[0] if ti_ok and ti_rd else 0
3525
+ intents = w3.codec.decode(["(uint256,uint256,uint256,bool,bool,bool)[]"], ui_rd)[0] if ui_ok and ui_rd else []
3501
3526
  print(f" {sym}: available {available / 10**dec:,.6f}, "
3502
3527
  f"pending intents {total_intent / 10**dec:,.6f}")
3503
3528
  for idx, (amt, actionable_at, expires_at, is_pending, is_actionable, is_expired) in enumerate(intents):
@@ -4473,13 +4498,33 @@ def cmd_lb_remove(pair_key: str, slippage_pct: float = 1.0, execute: bool = Fals
4473
4498
  return
4474
4499
 
4475
4500
  pair_c = _lb_pair_contract(w3, pair_cs)
4476
- active_id = pair_c.functions.getActiveId().call()
4501
+ # Batch getActiveId + per-bin (balanceOf + totalSupply + getBin) into one Multicall3
4502
+ # round-trip (was 1 + 3N sequential eth_calls). This is a WRITE path — the decoded
4503
+ # amounts feed removeLiquidity and the slippage floors — so a failed read aborts rather
4504
+ # than silently defaulting to 0 (matches the prior abort-on-error behaviour).
4505
+ legs = [(pair_cs, bytes.fromhex(pair_c.encode_abi("getActiveId", args=[])[2:]))]
4506
+ for binid in ids:
4507
+ legs.append((pair_cs, bytes.fromhex(pair_c.encode_abi("balanceOf", args=[pa_cs, binid])[2:])))
4508
+ legs.append((pair_cs, bytes.fromhex(pair_c.encode_abi("totalSupply", args=[binid])[2:])))
4509
+ legs.append((pair_cs, bytes.fromhex(pair_c.encode_abi("getBin", args=[binid])[2:])))
4510
+ results = multicall(w3, legs)
4511
+ a_ok, a_rd = results[0]
4512
+ if not (a_ok and a_rd):
4513
+ print(" Could not read the LB pair's active bin — refusing to remove.")
4514
+ return
4515
+ active_id = w3.codec.decode(["uint24"], a_rd)[0]
4477
4516
  amounts = []
4478
4517
  est_x = est_y = 0.0
4479
- for binid in ids:
4480
- bal = pair_c.functions.balanceOf(pa_cs, binid).call()
4481
- ts = pair_c.functions.totalSupply(binid).call()
4482
- rx, ry = pair_c.functions.getBin(binid).call()
4518
+ for k, binid in enumerate(ids):
4519
+ b_ok, b_rd = results[1 + 3 * k]
4520
+ t_ok, t_rd = results[1 + 3 * k + 1]
4521
+ g_ok, g_rd = results[1 + 3 * k + 2]
4522
+ if not (b_ok and b_rd and t_ok and t_rd and g_ok and g_rd):
4523
+ print(f" Could not read bin {binid} on [{pair_key}] — refusing to remove (incomplete data).")
4524
+ return
4525
+ bal = w3.codec.decode(["uint256"], b_rd)[0]
4526
+ ts = w3.codec.decode(["uint256"], t_rd)[0]
4527
+ rx, ry = w3.codec.decode(["uint128", "uint128"], g_rd)
4483
4528
  amounts.append(bal)
4484
4529
  share = (bal / ts) if ts else 0
4485
4530
  est_x += rx * share / 10**x_cfg["decimals"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.11.2
3
+ Version: 0.11.4
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.11.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.11.4 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.11.2"
7
+ version = "0.11.4"
8
8
  description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes