primecli 0.11.0__tar.gz → 0.11.2__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.0 → primecli-0.11.2}/PKG-INFO +2 -2
  2. {primecli-0.11.0 → primecli-0.11.2}/README.md +1 -1
  3. {primecli-0.11.0 → primecli-0.11.2}/primecli/arbprime.py +44 -7
  4. {primecli-0.11.0 → primecli-0.11.2}/primecli/degenprime.py +144 -43
  5. {primecli-0.11.0 → primecli-0.11.2}/primecli/deltaprime.py +52 -9
  6. {primecli-0.11.0 → primecli-0.11.2}/primecli/health_monitor.py +18 -15
  7. {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/PKG-INFO +2 -2
  8. {primecli-0.11.0 → primecli-0.11.2}/pyproject.toml +1 -1
  9. {primecli-0.11.0 → primecli-0.11.2}/tests/test_aero_v3_collision_fixes.py +62 -0
  10. {primecli-0.11.0 → primecli-0.11.2}/tests/test_paraswap_requote.py +24 -0
  11. {primecli-0.11.0 → primecli-0.11.2}/LICENSE +0 -0
  12. {primecli-0.11.0 → primecli-0.11.2}/primecli/__init__.py +0 -0
  13. {primecli-0.11.0 → primecli-0.11.2}/primecli/_flowledger.py +0 -0
  14. {primecli-0.11.0 → primecli-0.11.2}/primecli/_wallets.py +0 -0
  15. {primecli-0.11.0 → primecli-0.11.2}/primecli/bridge.py +0 -0
  16. {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/SOURCES.txt +0 -0
  17. {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/dependency_links.txt +0 -0
  18. {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/entry_points.txt +0 -0
  19. {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/requires.txt +0 -0
  20. {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/top_level.txt +0 -0
  21. {primecli-0.11.0 → primecli-0.11.2}/setup.cfg +0 -0
  22. {primecli-0.11.0 → primecli-0.11.2}/tests/test_aero_range_and_swap_fallback.py +0 -0
  23. {primecli-0.11.0 → primecli-0.11.2}/tests/test_aero_rebalance.py +0 -0
  24. {primecli-0.11.0 → primecli-0.11.2}/tests/test_bridge.py +0 -0
  25. {primecli-0.11.0 → primecli-0.11.2}/tests/test_cross_file_identity.py +0 -0
  26. {primecli-0.11.0 → primecli-0.11.2}/tests/test_flowledger_transferred_amount.py +0 -0
  27. {primecli-0.11.0 → primecli-0.11.2}/tests/test_gas_limit.py +0 -0
  28. {primecli-0.11.0 → primecli-0.11.2}/tests/test_gas_pricing.py +0 -0
  29. {primecli-0.11.0 → primecli-0.11.2}/tests/test_health_meter.py +0 -0
  30. {primecli-0.11.0 → primecli-0.11.2}/tests/test_health_monitor.py +0 -0
  31. {primecli-0.11.0 → primecli-0.11.2}/tests/test_paraswap_validator.py +0 -0
  32. {primecli-0.11.0 → primecli-0.11.2}/tests/test_redstone_encoding.py +0 -0
  33. {primecli-0.11.0 → primecli-0.11.2}/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.0
3
+ Version: 0.11.2
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.0 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.2 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.0 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.2 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
 
@@ -201,7 +201,7 @@ Only depositPrime (inside prime-activate --amount) is solvency-gated -> RedStone
201
201
  prime-* write is onlyOwner and needs no payload. All prime-* views are oracle-free. Preview by default; --execute broadcasts.
202
202
  """
203
203
 
204
- import json, os, sys, time, re, base64, struct
204
+ import json, os, sys, time, re, random, base64, struct
205
205
  from decimal import Decimal, ROUND_HALF_UP
206
206
  from pathlib import Path
207
207
  import requests
@@ -238,6 +238,10 @@ except ImportError:
238
238
 
239
239
  # Arbitrum One RPC. Override with ARBPRIME_RPC for a paid Alchemy/Infura endpoint.
240
240
  ARBITRUM_RPC = os.environ.get("ARBPRIME_RPC", "https://arb1.arbitrum.io/rpc")
241
+ # Secondary RPCs tried when the primary returns 429/5xx. Expanded list for resilience.
242
+ _ARBITRUM_RPC_FALLBACKS = [
243
+ "https://arbitrum.publicnode.com",
244
+ ]
241
245
  EXPLORER = "https://arbiscan.io" # display/links only — never used for ABI fetch
242
246
  CHAIN_ID = 42161
243
247
  ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
@@ -701,12 +705,33 @@ def multicall(w3, calls):
701
705
  _W3 = None
702
706
 
703
707
  def get_w3():
704
- """Process-local Arbitrum RPC client. Arbitrum is a standard rollup (like Base) — no
705
- POA middleware needed (and injecting it would error on Arbitrum block headers)."""
708
+ """Process-local Arbitrum RPC client with built-in fallback + exponential backoff.
709
+ Arbitrum is a standard rollup (like Base) no POA middleware needed."""
706
710
  global _W3
707
- if _W3 is None:
708
- _W3 = Web3(Web3.HTTPProvider(ARBITRUM_RPC))
709
- return _W3
711
+ if _W3 is not None:
712
+ return _W3
713
+ candidates = [ARBITRUM_RPC] + [u for u in _ARBITRUM_RPC_FALLBACKS if u != ARBITRUM_RPC]
714
+ last_exc = None
715
+ for attempt, url in enumerate(candidates * 2): # At most 2 rounds through the list
716
+ try:
717
+ w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 10}))
718
+ # eth_chainId is cheaper than block_number for health checks
719
+ w3.eth.chain_id
720
+ _W3 = w3
721
+ return _W3
722
+ except Exception as e:
723
+ last_exc = e
724
+ msg = str(e)
725
+ if any(code in msg for code in ("429", "500", "502", "503", "Connection", "Timeout")):
726
+ # Exponential backoff with jitter: 0.5s, 1s, 2s, 4s, 8s, capped at 16s
727
+ delay = min(2 ** (attempt // len(candidates)) * 0.5, 16.0)
728
+ delay += random.uniform(0, delay * 0.3)
729
+ time.sleep(delay)
730
+ continue
731
+ raise
732
+ if last_exc:
733
+ raise last_exc
734
+ raise RuntimeError(f"All {len(candidates)} Arbitrum RPCs failed, last: {last_exc}")
710
735
 
711
736
  def _tx_gas_price(w3) -> int:
712
737
  """Estimated per-gas cost for balance checks (gas buffer pre-flights). Returns 2x the
@@ -1174,6 +1199,15 @@ def get_prime_account(w3, owner: str) -> str:
1174
1199
  def asset_b32(symbol: str) -> bytes:
1175
1200
  return symbol.encode().ljust(32, b"\x00")
1176
1201
 
1202
+ def _account_asset_symbol(symbol: str) -> str:
1203
+ """Map a display/pool symbol to the bytes32 symbol the account stores on-chain.
1204
+ Identity on Arbitrum today — every pool/token config already uses the canonical
1205
+ account symbol and Arbitrum lists no aliased assets (no EURC/EUROC here). Kept so
1206
+ _resolve_debt_coverages stays byte-identical with the degenprime copy, where Base
1207
+ pool configs use "EURC" and must be normalized to the account's "EUROC" before
1208
+ getAssetAddress can resolve it."""
1209
+ return symbol
1210
+
1177
1211
  def pool_to_asset_symbol(pool_name: str) -> str:
1178
1212
  """Pool key -> on-chain bytes32 asset symbol (the contracts use 'ETH', not 'WETH';
1179
1213
  'BTC', not 'WBTC')."""
@@ -2247,7 +2281,10 @@ def _resolve_debt_coverages(w3, symbols: list, tier_code: int = 0) -> dict:
2247
2281
  '{"inputs":[{"name":"t","type":"uint8"},{"name":"a","type":"address"}],"name":"tieredDebtCoverage",'
2248
2282
  '"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2249
2283
  tm = w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=tm_abi)
2250
- addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(s), True])[2:]))
2284
+ # Resolve through the account's bytes32 alias (identity on this chain; see
2285
+ # _account_asset_symbol) so this batched resolver stays byte-identical with the
2286
+ # degenprime copy, where Base configs use "EURC" for the account symbol "EUROC".
2287
+ addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(_account_asset_symbol(s)), True])[2:]))
2251
2288
  for s in missing]
2252
2289
  addr_res = multicall(w3, addr_legs)
2253
2290
  addrs = {}
@@ -27,8 +27,9 @@ Usage:
27
27
  degenprime execute-withdrawal --pool usdc [--index N] [--execute]
28
28
  degenprime cancel-withdrawal --pool usdc --index N [--execute]
29
29
  degenprime aerodrome-positions
30
- degenprime aero-add-liquidity --pool weth-usdc-100 --amount-weth 0.05 --amount-usdc 100 [--slippage 1] [--execute]
31
- degenprime aero-add-liquidity --pool weth-usdc-100 --use-all-available [--width 2] [--slippage 1] [--execute]
30
+ degenprime aero-add-liquidity --pool weth-usdc-100 --amount-token0 0.05 --amount-token1 100 [--slippage 1] [--execute]
31
+ degenprime aero-add-liquidity --pool virtual-weth-50-v3 --amount-token0 100 --amount-token1 0.05 [--slippage 1] [--execute]
32
+ degenprime aero-add-liquidity --pool weth-euroc-100-v3 --use-all-available [--width 2] [--slippage 1] [--execute]
32
33
  degenprime aero-increase-liquidity --pool weth-usdc-100 --token-id N --amount-token0 X --amount-token1 Y [--slippage 1] [--execute]
33
34
  degenprime aero-remove-liquidity --token-id N [--token-id M ...] [--execute] (full close only)
34
35
  degenprime aero-collect-fees --token-id N [--execute]
@@ -125,7 +126,7 @@ position (a NEW tokenId) when price drifts past the trigger. Subcommands:
125
126
  primitive; not solvency-gated (no RedStone).
126
127
  """
127
128
 
128
- import json, os, sys, time, base64
129
+ import json, os, sys, time, random, base64
129
130
  from decimal import Decimal, ROUND_HALF_UP, localcontext
130
131
  from pathlib import Path
131
132
  import requests
@@ -162,7 +163,14 @@ except ImportError:
162
163
  # and has been the most reliable free option for this tool's traffic pattern (lots of
163
164
  # small reads in quick succession for `pool-info all` / `my-positions` / `summary`).
164
165
  # Override with the DEGENPRIME_RPC env var for paid Alchemy/QuickNode/Infura endpoints.
165
- BASE_RPC = os.environ.get("DEGENPRIME_RPC", "https://base.publicnode.com")
166
+ BASE_RPC = os.environ.get("DEGENPRIME_RPC", "https://base-rpc.publicnode.com")
167
+ # Secondary RPCs tried when the primary returns 429/5xx. Expanded list so the
168
+ # burst of overlapping cron processes spread across more endpoints instead of
169
+ # pounding the same 2 into 429. No in-flight retry within a single call.
170
+ _BASE_RPC_FALLBACKS = [
171
+ "https://base.drpc.org",
172
+ "https://base.meowrpc.com",
173
+ ]
166
174
  EXPLORER = "https://basescan.org"
167
175
  CHAIN_ID = 8453
168
176
  ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
@@ -490,12 +498,39 @@ _asset_meta_cache = {}
490
498
  _W3 = None
491
499
 
492
500
  def get_w3():
493
- """Process-local Base RPC client. Base has no POA middleware - it's a standard EVM
494
- chain; middleware injection is not needed (and would error on Base block headers)."""
501
+ """Process-local Base RPC client with built-in fallback + exponential backoff.
502
+ Base has no POA middleware - it's a standard EVM chain; middleware injection is
503
+ not needed (and would error on Base block headers).
504
+
505
+ Tries the primary RPC first (BASE_RPC / DEGENPRIME_RPC env). On 429/5xx, rotates
506
+ through _BASE_RPC_FALLBACKS with exponential backoff + jitter so multiple
507
+ concurrent processes don't thundering-herd the same fallback endpoint.
508
+ The first working RPC is cached for the process lifetime."""
495
509
  global _W3
496
- if _W3 is None:
497
- _W3 = Web3(Web3.HTTPProvider(BASE_RPC))
498
- return _W3
510
+ if _W3 is not None:
511
+ return _W3
512
+ candidates = [BASE_RPC] + [u for u in _BASE_RPC_FALLBACKS if u != BASE_RPC]
513
+ last_exc = None
514
+ for attempt, url in enumerate(candidates * 2): # At most 2 rounds through the list
515
+ try:
516
+ w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 10}))
517
+ # Quick health check — eth_chainId is cheaper for providers than block_number
518
+ w3.eth.chain_id
519
+ _W3 = w3
520
+ return _W3
521
+ except Exception as e:
522
+ last_exc = e
523
+ msg = str(e)
524
+ if any(code in msg for code in ("429", "500", "502", "503", "Connection", "Timeout")):
525
+ # Exponential backoff with jitter: 0.5s, 1s, 2s, 4s, 8s, capped at 16s
526
+ delay = min(2 ** (attempt // len(candidates)) * 0.5, 16.0)
527
+ delay += random.uniform(0, delay * 0.3) # 30% jitter
528
+ time.sleep(delay)
529
+ continue
530
+ raise
531
+ if last_exc:
532
+ raise last_exc
533
+ raise RuntimeError(f"All {len(candidates)} Base RPCs failed, last: {last_exc}")
499
534
 
500
535
  def _tx_gas_price(w3) -> int:
501
536
  """Estimated per-gas cost for balance checks (gas buffer pre-flights). Returns 2x the
@@ -1068,6 +1103,12 @@ def get_prime_account(w3, owner: str) -> str:
1068
1103
  def asset_b32(symbol: str) -> bytes:
1069
1104
  return symbol.encode().ljust(32, b"\x00")
1070
1105
 
1106
+ def _account_asset_symbol(symbol: str) -> str:
1107
+ """Map display symbols to the bytes32 symbols DegenPrime stores on accounts."""
1108
+ if str(symbol).upper() == "EURC":
1109
+ return "EUROC"
1110
+ return symbol
1111
+
1071
1112
  def fmt_token_amount(raw: int, decimals: int) -> str:
1072
1113
  """Human token amount that never misleadingly rounds a dust balance UP.
1073
1114
 
@@ -1110,6 +1151,7 @@ def _asset_meta(w3, symbol: str):
1110
1151
  Used for in-account assets that aren't lending pool symbols (memecoin collateral
1111
1152
  like AIXBT, TOSHI, etc.). Cached - reads are pure but the TokenManager call is one
1112
1153
  eth_call + an ERC20.decimals() per asset."""
1154
+ symbol = _account_asset_symbol(symbol)
1113
1155
  if symbol in _asset_meta_cache:
1114
1156
  return _asset_meta_cache[symbol]
1115
1157
  # Pool symbols hit the static map - no on-chain read needed.
@@ -1245,7 +1287,15 @@ def _resolve_debt_coverages(w3, symbols: list, tier_code: int = 0) -> dict:
1245
1287
  '{"inputs":[{"name":"t","type":"uint8"},{"name":"a","type":"address"}],"name":"tieredDebtCoverage",'
1246
1288
  '"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
1247
1289
  tm = w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=tm_abi)
1248
- addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(s), True])[2:]))
1290
+ # Resolve the address through the account's bytes32 alias, not the raw display
1291
+ # symbol: DegenPrime's TokenManager stores EURC as "EUROC", so getAssetAddress
1292
+ # for "EURC" returns the zero address → dc=0 → a leveraged ETH/EURC LP read as
1293
+ # 0% health even while solvent (health_ratio 1.29). The single-symbol resolver
1294
+ # (_asset_meta) already normalizes via _account_asset_symbol; this batched path
1295
+ # did not. Keep the output keyed by the original symbol so callers' lookups
1296
+ # (which use the display symbol) still hit. (Bruno, 2026-06-27.)
1297
+ addr_legs = [(TOKEN_MANAGER, bytes.fromhex(
1298
+ tm.encode_abi("getAssetAddress", args=[asset_b32(_account_asset_symbol(s)), True])[2:]))
1249
1299
  for s in missing]
1250
1300
  addr_res = multicall(w3, addr_legs)
1251
1301
  addrs = {}
@@ -3087,6 +3137,7 @@ def _swap_asset_meta(w3, symbol: str):
3087
3137
  """Resolve a swap-side symbol to {token, decimals}. Falls back to TokenManager for
3088
3138
  non-pool collateral (memecoins). Returns None if the asset is unknown.
3089
3139
  Lookup is case-insensitive (keys like cbBTC match CBBTC)."""
3140
+ symbol = _account_asset_symbol(symbol)
3090
3141
  if symbol in SWAP_ASSETS:
3091
3142
  return SWAP_ASSETS[symbol]
3092
3143
  # Case-insensitive fallback for mixed-case symbols like cbBTC.
@@ -3101,17 +3152,19 @@ def _swap_asset_meta(w3, symbol: str):
3101
3152
  def _paraswap_price_route(src_token, src_dec, dest_token, dest_dec, amount_in_wei, user_addr):
3102
3153
  """ParaSwap /prices on Base (network=8453, v6.2). Returns the priceRoute dict for a
3103
3154
  SELL of amount_in_wei src->dest. The priceRoute is passed verbatim to /transactions.
3104
- excludeContractMethods is hard-coded to keep ParaSwap from picking a router method
3105
- the facet can't decode (multiSwap/megaSwap/protected* etc.). excludeDEXS drops the
3106
- RFQ/maker sources (AugustusRFQ, Hashflow, etc.) up front: those won't fill for a
3107
- contract caller and are the prime SwapFailed() culprits - cutting them at the quote
3108
- keeps the re-quote loop from churning through routes that can never simulate clean."""
3155
+ includeContractMethods pins the API to exactly the two router methods the facet can
3156
+ decode (PARASWAP_SUPPORTED_SELECTORS) - an allowlist, not a blocklist, so a future
3157
+ ParaSwap route type the facet still can't decode is excluded by construction instead
3158
+ of needing a matching blocklist entry. excludeDEXS drops the RFQ/maker sources
3159
+ (AugustusRFQ, Hashflow, etc.) up front: those won't fill for a contract caller and
3160
+ are the prime SwapFailed() culprits - cutting them at the quote keeps the re-quote
3161
+ loop from churning through routes that can never simulate clean."""
3109
3162
  params = {
3110
3163
  "srcToken": src_token, "srcDecimals": src_dec,
3111
3164
  "destToken": dest_token, "destDecimals": dest_dec,
3112
3165
  "amount": str(amount_in_wei), "side": "SELL",
3113
3166
  "network": CHAIN_ID, "version": "6.2", "userAddress": user_addr,
3114
- "excludeContractMethods": "multiSwap,megaSwap,protectedMultiSwap,protectedMegaSwap,protectedSimpleSwap,simpleSwap,swapExactAmountInOnCurveV1",
3167
+ "includeContractMethods": "swapExactAmountIn,swapExactAmountInOnUniswapV3",
3115
3168
  "excludeDEXS": "AugustusRFQ,ParaSwapLimitOrders,Hashflow,Bebop,Swaap,SwaapV2,DexalotRFQ,NativeV1,Clipper,Metric,MetricRFQ",
3116
3169
  }
3117
3170
  r = requests.get(f"{PARASWAP_API}/prices", params=params,
@@ -3406,7 +3459,12 @@ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float
3406
3459
  if borrowed == 0:
3407
3460
  print(f"Degen Account has no {from_sym} debt to refinance.")
3408
3461
  return
3409
- repay_amount = min(to_wei_units(amount, from_cfg["decimals"]), borrowed)
3462
+ requested_wei = to_wei_units(amount, from_cfg["decimals"])
3463
+ repay_amount = min(requested_wei, borrowed)
3464
+ if requested_wei > borrowed:
3465
+ print(f" Warning: requested {amount} {from_sym} exceeds the "
3466
+ f"{borrowed / 10**from_cfg['decimals']:.6f} {from_sym} debt "
3467
+ f"(--amount is in {from_sym} units, not USD) — capping to the full debt.")
3410
3468
 
3411
3469
  feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
3412
3470
  payload = build_redstone_payload(feeds)
@@ -4003,7 +4061,7 @@ def _aero_pool_address(pool_cfg: dict) -> str:
4003
4061
  if baked:
4004
4062
  return Web3.to_checksum_address(baked)
4005
4063
  try:
4006
- w3_local = Web3(Web3.HTTPProvider(BASE_RPC))
4064
+ w3_local = get_w3()
4007
4065
  factory_addr = (AERODROME_CL_FACTORY_V3
4008
4066
  if pool_cfg.get("slipstreamVersion", 0) == 1
4009
4067
  else AERODROME_CL_FACTORY_V2)
@@ -4042,12 +4100,13 @@ def _aero_in_account_balance(account, symbol: str) -> int:
4042
4100
  same balance getBalance(bytes32) reports (verified equal to ERC20.balanceOf on
4043
4101
  the account 2026-06-14). Subtract any pending withdrawal-intent lock so we never
4044
4102
  treat reserved funds as available. Returns 0 if the view reverts."""
4103
+ account_symbol = _account_asset_symbol(symbol)
4045
4104
  try:
4046
- bal = account.functions.getBalance(asset_b32(symbol)).call()
4105
+ bal = account.functions.getBalance(asset_b32(account_symbol)).call()
4047
4106
  except Exception:
4048
4107
  return 0
4049
4108
  try:
4050
- locked = account.functions.getTotalIntentAmount(asset_b32(symbol)).call()
4109
+ locked = account.functions.getTotalIntentAmount(asset_b32(account_symbol)).call()
4051
4110
  except Exception:
4052
4111
  locked = 0
4053
4112
  return bal - locked if bal > locked else 0
@@ -4284,6 +4343,32 @@ def _swap_with_usdc_fallback(account, from_sym, to_sym, amount_human,
4284
4343
  return True
4285
4344
 
4286
4345
 
4346
+ def _aero_separate_pool_and_sweeps(valuable: dict, sym0: str, sym1: str):
4347
+ """Split a {symbol: balance_wei} inventory into the two pool-token balances
4348
+ and the non-pool "sweep" assets, returning (pool0_bal_wei, pool1_bal_wei,
4349
+ sweeps).
4350
+
4351
+ Comparison is on NORMALIZED account symbols (_account_asset_symbol), so an
4352
+ account alias of a pool token — EURC held/queried as EUROC — is attributed to
4353
+ its pool leg instead of the sweep bucket. A raw string compare ("EUROC" ==
4354
+ "EURC") dropped the real pool-token balance into `sweeps`, which --execute
4355
+ would then swap away before the mint even though it was also counted as the
4356
+ pool leg. Same alias dedup _aero_rebuild_sweep already applies (ba9ae34)."""
4357
+ norm0, norm1 = _account_asset_symbol(sym0), _account_asset_symbol(sym1)
4358
+ pool0_bal_wei = 0
4359
+ pool1_bal_wei = 0
4360
+ sweeps = {}
4361
+ for sym, bal_wei in valuable.items():
4362
+ norm = _account_asset_symbol(sym)
4363
+ if norm == norm0:
4364
+ pool0_bal_wei = bal_wei
4365
+ elif norm == norm1:
4366
+ pool1_bal_wei = bal_wei
4367
+ else:
4368
+ sweeps[sym] = bal_wei
4369
+ return pool0_bal_wei, pool1_bal_wei, sweeps
4370
+
4371
+
4287
4372
  def _aero_use_all_available(
4288
4373
  w3, acct, account, pa_cs, pool_cfg,
4289
4374
  width_pct=2.0, slippage_pct=1.0, execute=False,
@@ -4309,14 +4394,15 @@ def _aero_use_all_available(
4309
4394
  all_candidates = set(REDSTONE_AVAILABLE_FEEDS) | {sym0, sym1}
4310
4395
  inventory = {}
4311
4396
  for sym in sorted(all_candidates):
4397
+ account_sym = _account_asset_symbol(sym)
4312
4398
  try:
4313
- bal = account.functions.getBalance(asset_b32(sym)).call()
4399
+ bal = account.functions.getBalance(asset_b32(account_sym)).call()
4314
4400
  except Exception:
4315
4401
  bal = 0
4316
4402
  if bal <= 0:
4317
4403
  continue
4318
4404
  try:
4319
- locked = account.functions.getTotalIntentAmount(asset_b32(sym)).call()
4405
+ locked = account.functions.getTotalIntentAmount(asset_b32(account_sym)).call()
4320
4406
  except Exception:
4321
4407
  locked = 0
4322
4408
  avail = bal - locked if bal > locked else 0
@@ -4382,17 +4468,11 @@ def _aero_use_all_available(
4382
4468
  print(" No available assets to deploy.")
4383
4469
  return False
4384
4470
 
4385
- # 5. Identify non-pool assets (to sweep) and pool-token balances
4386
- sweeps = {}
4387
- pool0_bal_wei = valuable.get(sym0, 0)
4388
- pool1_bal_wei = valuable.get(sym1, 0)
4389
- for sym, bal_wei in valuable.items():
4390
- if sym == sym0:
4391
- pool0_bal_wei = bal_wei
4392
- elif sym == sym1:
4393
- pool1_bal_wei = bal_wei
4394
- else:
4395
- sweeps[sym] = bal_wei
4471
+ # 5. Identify non-pool assets (to sweep) and pool-token balances. Alias-aware
4472
+ # (EURC/EUROC) so the same underlying is never both a pool leg and a sweep
4473
+ # target — see _aero_separate_pool_and_sweeps.
4474
+ pool0_bal_wei, pool1_bal_wei, sweeps = _aero_separate_pool_and_sweeps(
4475
+ valuable, sym0, sym1)
4396
4476
 
4397
4477
  # 6. Compute optimal allocation for pool tokens
4398
4478
  tick_lower, tick_upper = _aero_tick_range(
@@ -4534,9 +4614,11 @@ def cmd_aero_add_liquidity(pool_key: str, amount0: float = None,
4534
4614
  """Add concentrated liquidity to an Aerodrome Slipstream pool through the
4535
4615
  Degen Account's AerodromeFacet. Uses in-account token0/token1 balances.
4536
4616
 
4537
- --pool selects a whitelisted CL pool (e.g. weth-usdc-100).
4538
- --amount-weth / --amount-usdc (or --amount-token0 / --amount-token1) specify
4539
- the desired liquidity amounts in token units. At least one side must be >0.
4617
+ --pool selects a whitelisted CL pool (e.g. weth-usdc-100,
4618
+ virtual-weth-50-v3, weth-euroc-100-v3).
4619
+ --amount-token0 / --amount-token1 specify the desired liquidity amounts in
4620
+ the pool's token0/token1 units. Legacy --amount-weth / --amount-usdc aliases
4621
+ are accepted only for WETH/USDC-shaped pools. At least one side must be >0.
4540
4622
  --slippage sets the min-amount floor (default 1%).
4541
4623
  --width sets the range +/-width% around the current price (default 2%).
4542
4624
 
@@ -4957,6 +5039,16 @@ def cmd_aero_rebuild(token_id: int, width_pct: float = 2.0, slippage_pct: float
4957
5039
  sys.exit(2)
4958
5040
  pool_cfg = AERODROME_POOLS[pool_key]
4959
5041
 
5042
+ # ⚠️ Aerodrome V3 gauge anti-sniping (10-second cooldown). The
5043
+ # batchRemoveStakedLiquidityAerodrome (Step 1) unstaked from the gauge at
5044
+ # block.timestamp. V3 gauges reject a new stake within 10 seconds of a
5045
+ # withdraw by the same address. Sleep 14s to ensure the cooldown has elapsed
5046
+ # before the mint+stake in Step 3, even when the sweep step has no swaps.
5047
+ if pool_cfg.get("slipstreamVersion", 0) >= 1 and execute:
5048
+ print(f" V3 gauge anti-sniping: waiting 14s before re-staking...")
5049
+ import time
5050
+ time.sleep(14)
5051
+
4960
5052
  print(f"\nStep 2: Sweeping idle assets >$5 to pool {pool_key}...")
4961
5053
  _aero_rebuild_sweep(w3, acct, account, pa_cs, pool_key, pool_cfg, execute=execute)
4962
5054
 
@@ -5552,7 +5644,7 @@ def _aero_rebalance_events(account: str, from_block=None, to_block="latest"):
5552
5644
  CHUNK_FLOOR = 2_000
5553
5645
  t0_to_name = {t.lower().removeprefix("0x"): n for n, t in REBALANCE_TOPIC0.items()}
5554
5646
  all_topic0 = list(REBALANCE_TOPIC0.values())
5555
- scan_w3 = Web3(Web3.HTTPProvider(BASE_RPC, request_kwargs={"timeout": 20}))
5647
+ scan_w3 = get_w3()
5556
5648
  out = []
5557
5649
  start = from_block
5558
5650
  chunk = CHUNK
@@ -5838,14 +5930,15 @@ def _aero_rebuild_sweep(w3, acct, account, pa_cs, pool_key, pool_cfg=None, execu
5838
5930
  candidates = set(REDSTONE_AVAILABLE_FEEDS) | {sym0, sym1}
5839
5931
  inventory = {}
5840
5932
  for sym in sorted(candidates):
5933
+ account_sym = _account_asset_symbol(sym)
5841
5934
  try:
5842
- bal = account.functions.getBalance(asset_b32(sym)).call()
5935
+ bal = account.functions.getBalance(asset_b32(account_sym)).call()
5843
5936
  except Exception:
5844
5937
  bal = 0
5845
5938
  if bal <= 0:
5846
5939
  continue
5847
5940
  try:
5848
- locked = account.functions.getTotalIntentAmount(asset_b32(sym)).call()
5941
+ locked = account.functions.getTotalIntentAmount(asset_b32(account_sym)).call()
5849
5942
  except Exception:
5850
5943
  locked = 0
5851
5944
  avail = bal - locked if bal > locked else 0
@@ -5885,9 +5978,12 @@ def _aero_rebuild_sweep(w3, acct, account, pa_cs, pool_key, pool_cfg=None, execu
5885
5978
  return
5886
5979
 
5887
5980
  # Determine which non-pool assets to sweep
5981
+ # Normalize via _account_asset_symbol so symbol aliases (EURC/EUROC) don't
5982
+ # register the same underlying token as both pool-token and sweep-target.
5983
+ _pool_norm = {_account_asset_symbol(sym0), _account_asset_symbol(sym1)}
5888
5984
  sweeps = {}
5889
5985
  for sym, bal_wei in valuable.items():
5890
- if sym not in (sym0, sym1):
5986
+ if _account_asset_symbol(sym) not in _pool_norm:
5891
5987
  sweeps[sym] = bal_wei
5892
5988
 
5893
5989
  if not sweeps:
@@ -5972,6 +6068,10 @@ def cmd_aero_rebalance_create(token_id: int, width_pct: float, mode: str = "outs
5972
6068
  if pool_key:
5973
6069
  print(f" Auto-sweeping idle assets to pool: {pool_key}...")
5974
6070
  _aero_rebuild_sweep(w3, acct, account, pa_cs, pool_key, execute=execute)
6071
+ if pool_key.endswith("-v3"):
6072
+ print(f" (Note: {pool_key} is a Gauges-V3 pool with a 10-second")
6073
+ print(f" anti-sniping cooldown. The protocol's executor should handle")
6074
+ print(f" this; off-chain rebuilds via `aero-rebuild` also respect it.")
5975
6075
 
5976
6076
  try:
5977
6077
  params, preview = _build_rebalance_order_params(
@@ -6261,11 +6361,12 @@ def _dispatch():
6261
6361
  if a == "--width" and i + 1 < len(args): width = float(args[i + 1])
6262
6362
  use_all = "--use-all-available" in args
6263
6363
  if not pool_key:
6264
- print("Usage: degenprime aero-add-liquidity --pool weth-usdc-100 --amount-weth 0.05 --amount-usdc 100 [--slippage 1] [--width 2] [--execute]")
6364
+ print("Usage: degenprime aero-add-liquidity --pool <pool-key> --amount-token0 X --amount-token1 Y [--slippage 1] [--width 2] [--execute]")
6365
+ print("Example V3: degenprime aero-add-liquidity --pool virtual-weth-50-v3 --amount-token0 100 --amount-token1 0.05")
6265
6366
  return
6266
6367
  if not use_all and (amt0 is None and amt1 is None):
6267
- print("Specify --amount-* values or add --use-all-available to auto-detect.")
6268
- print("Usage: degenprime aero-add-liquidity --pool weth-usdc-100 [--use-all-available] [--slippage 1] [--width 2] [--execute]")
6368
+ print("Specify --amount-token0 / --amount-token1 values or add --use-all-available to auto-detect.")
6369
+ print("Usage: degenprime aero-add-liquidity --pool <pool-key> [--use-all-available] [--slippage 1] [--width 2] [--execute]")
6269
6370
  return
6270
6371
  if use_all:
6271
6372
  cmd_aero_add_liquidity(pool_key, slippage_pct=slippage, execute=execute, width_pct=width, use_all_available=True)
@@ -209,7 +209,7 @@ Only depositPrime (inside prime-activate --amount) is solvency-gated -> RedStone
209
209
  prime-* write is onlyOwner and needs no payload. All prime-* views are oracle-free. Preview by default; --execute broadcasts.
210
210
  """
211
211
 
212
- import json, os, sys, time, re, base64, struct
212
+ import json, os, sys, time, re, random, base64, struct
213
213
  from decimal import Decimal, ROUND_HALF_UP
214
214
  from pathlib import Path
215
215
  import requests
@@ -246,6 +246,12 @@ except ImportError:
246
246
  def check_version(*a, **kw): pass
247
247
 
248
248
  AVALANCHE_RPC = os.environ.get("DELTAPRIME_RPC", "https://api.avax.network/ext/bc/C/rpc")
249
+ # Secondary RPCs tried when the primary returns 429/5xx. Expanded list spreads
250
+ # concurrent cron process load across more free endpoints.
251
+ _AVALANCHE_RPC_FALLBACKS = [
252
+ "https://avalanche.publicnode.com",
253
+ "https://avalanche.drpc.org",
254
+ ]
249
255
  EXPLORER = "https://snowtrace.io"
250
256
  CHAIN_ID = 43114
251
257
  ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
@@ -699,15 +705,40 @@ _impl_cache = {}
699
705
  # HTTPProvider — wasteful on multi-pool reads (cmd_pool_info("all"), gather_defi).
700
706
  _W3 = None
701
707
 
708
+ def _make_avax_w3(rpc_url: str):
709
+ w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 10}))
710
+ w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
711
+ return w3
712
+
713
+
702
714
  def get_w3():
703
- """Process-local Web3 client. Avalanche C-chain needs the POA middleware injected
704
- once; subsequent callers share the same provider + middleware stack."""
715
+ """Process-local Web3 client with built-in fallback + exponential backoff on 429/5xx.
716
+ Avalanche C-chain needs the POA middleware injected once."""
705
717
  global _W3
706
- if _W3 is None:
707
- w3 = Web3(Web3.HTTPProvider(AVALANCHE_RPC))
708
- w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
709
- _W3 = w3
710
- return _W3
718
+ if _W3 is not None:
719
+ return _W3
720
+ candidates = [AVALANCHE_RPC] + [u for u in _AVALANCHE_RPC_FALLBACKS if u != AVALANCHE_RPC]
721
+ last_exc = None
722
+ for attempt, url in enumerate(candidates * 2): # At most 2 rounds through the list
723
+ try:
724
+ w3 = _make_avax_w3(url)
725
+ # eth_chainId is cheaper than block_number for health checks
726
+ w3.eth.chain_id
727
+ _W3 = w3
728
+ return _W3
729
+ except Exception as e:
730
+ last_exc = e
731
+ msg = str(e)
732
+ if any(code in msg for code in ("429", "500", "502", "503", "Connection", "Timeout")):
733
+ # Exponential backoff with jitter: 0.5s, 1s, 2s, 4s, 8s, capped at 16s
734
+ delay = min(2 ** (attempt // len(candidates)) * 0.5, 16.0)
735
+ delay += random.uniform(0, delay * 0.3)
736
+ time.sleep(delay)
737
+ continue
738
+ raise
739
+ if last_exc:
740
+ raise last_exc
741
+ raise RuntimeError(f"All {len(candidates)} Avalanche RPCs failed, last: {last_exc}")
711
742
 
712
743
  def _tx_gas_price(w3) -> int:
713
744
  """Estimated per-gas cost for balance checks (gas buffer pre-flights). Returns 2x the
@@ -1205,6 +1236,15 @@ def get_prime_account(w3, owner: str) -> str:
1205
1236
  def asset_b32(symbol: str) -> bytes:
1206
1237
  return symbol.encode().ljust(32, b"\x00")
1207
1238
 
1239
+ def _account_asset_symbol(symbol: str) -> str:
1240
+ """Map a display/pool symbol to the bytes32 symbol the account stores on-chain.
1241
+ Identity on Avalanche today — every pool/token config already uses the canonical
1242
+ account symbol (the euro coin is configured as "EUROC", not "EURC"; WAVAX as "AVAX";
1243
+ WETH as "ETH"). Kept so _resolve_debt_coverages stays byte-identical with the
1244
+ degenprime copy, where Base pool configs use "EURC" and must be normalized to the
1245
+ account's "EUROC" before getAssetAddress can resolve it."""
1246
+ return symbol
1247
+
1208
1248
  def pool_to_asset_symbol(pool_name: str) -> str:
1209
1249
  """Pool key -> on-chain bytes32 asset symbol (the contracts use 'AVAX', not 'WAVAX')."""
1210
1250
  return POOLS[pool_name]["symbol"]
@@ -2266,7 +2306,10 @@ def _resolve_debt_coverages(w3, symbols: list, tier_code: int = 0) -> dict:
2266
2306
  '{"inputs":[{"name":"t","type":"uint8"},{"name":"a","type":"address"}],"name":"tieredDebtCoverage",'
2267
2307
  '"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2268
2308
  tm = w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=tm_abi)
2269
- addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(s), True])[2:]))
2309
+ # Resolve through the account's bytes32 alias (identity on this chain; see
2310
+ # _account_asset_symbol) so this batched resolver stays byte-identical with the
2311
+ # degenprime copy, where Base configs use "EURC" for the account symbol "EUROC".
2312
+ addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(_account_asset_symbol(s)), True])[2:]))
2270
2313
  for s in missing]
2271
2314
  addr_res = multicall(w3, addr_legs)
2272
2315
  addrs = {}
@@ -622,6 +622,7 @@ def _delever_lp_positions(
622
622
  strategy: dict,
623
623
  state_dir: str,
624
624
  label: str,
625
+ health_pct: float | None = None,
625
626
  dry_run: bool = False,
626
627
  ) -> dict:
627
628
  """Close LP positions to free assets for USDC debt repayment.
@@ -698,7 +699,7 @@ def _delever_lp_positions(
698
699
  }))
699
700
  detail = f"GMX {market_label}: withdraw {gm_to_withdraw:.4f} GM submitted (keeper pending)"
700
701
  result = {"ok": True, "async": True, "freed": 0.0, "detail": detail}
701
- _notify(f"\U0001f504 De-lever: {label} {detail}")
702
+ _notify(f"🔄 ⚠️ De-lever on {label}: health low — withdrawing GMX position to free collateral. {detail}")
702
703
  else:
703
704
  err = r.stderr[:300]
704
705
  result = {"ok": False, "error": f"gmx-withdraw failed: {err}"}
@@ -734,7 +735,7 @@ def _delever_lp_positions(
734
735
  if r.returncode == 0:
735
736
  detail = f"closed Aerodrome tokenId {token_id}"
736
737
  result = {"ok": True, "freed": item_usd, "detail": detail}
737
- _notify(f"\U0001f504 De-lever: {label} closed Aerodrome tokenId {token_id}")
738
+ _notify(f"🔄 ⚠️ De-lever on {label}: health was {health_pct}% — closing Aerodrome LP position #{token_id} (${item_usd:.2f} worth of collateral)")
738
739
  else:
739
740
  err = r.stderr[:300]
740
741
  result = {"ok": False, "error": f"aero-remove-liquidity failed: {err}"}
@@ -787,7 +788,7 @@ def _delever_lp_positions(
787
788
  if r.returncode == 0:
788
789
  detail = f"closed LB {pair}"
789
790
  result = {"ok": True, "freed": 0.0, "detail": detail}
790
- _notify(f"\U0001f504 De-lever: {label} closed LB {pair}")
791
+ _notify(f"🔄 ⚠️ De-lever on {label}: health was {health_pct}% — closing TraderJoe LB position ({pair})")
791
792
  else:
792
793
  err = r.stderr[:300]
793
794
  result = {"ok": False, "error": f"lb-remove failed: {err}"}
@@ -862,7 +863,7 @@ def _check_pending_gmx_delever(state_dir: str, defi_data: dict, label: str) -> d
862
863
  lvl = "partial" if gm_now > 0 else "full"
863
864
  detail = f"{market}: GM {gm_before:.2f} -> {gm_now:.2f} ({lvl} close)"
864
865
  marker_path.unlink(missing_ok=True)
865
- _notify(f"\u2705 GMX de-lever {label}: {detail}")
866
+ _notify(f" GMX de-lever on {label}: withdraw complete — {detail}")
866
867
  return {"settled": True, "detail": detail}
867
868
 
868
869
  if age > _GMX_PENDING_MAX_AGE:
@@ -1254,7 +1255,7 @@ def run_tick(
1254
1255
  lp_result = _delever_lp_positions(
1255
1256
  defi_data, tool_path, lp_shortfall,
1256
1257
  strategy, state_dir, label,
1257
- dry_run=dry_run,
1258
+ health_pct=pct, dry_run=dry_run,
1258
1259
  )
1259
1260
  # DRY-RUN: _delever_lp_positions broadcast NOTHING. Report what it
1260
1261
  # WOULD have closed/repaid and stop — never touch the chain.
@@ -1348,9 +1349,9 @@ def run_tick(
1348
1349
  "health_pct": pct, "label": label,
1349
1350
  })
1350
1351
  _notify(
1351
- f"🚨 {label}: de-lever LP closed but debt remains "
1352
- f"(${repay_amt:.2f} to repay, nothing freed/repayable). "
1353
- f"Health {pct}%. Manual unwind needed."
1352
+ f"🚨 {label}: LP position closed but CANNOT repay debt "
1353
+ f"no freed tokens matched any existing debt leg. "
1354
+ f"Still owe ${repay_amt:.2f}, health at {pct}%. Manual intervention needed."
1354
1355
  )
1355
1356
  result["action"] = "escalate (debt remains after LP close)"
1356
1357
  return result
@@ -1363,8 +1364,9 @@ def run_tick(
1363
1364
  if remaining_after < 1.0 or usdc_shortfall < 0.50:
1364
1365
  result["action"] = f"repaid ${repaid_usd:.2f} (LP de-lever, token-matched)"
1365
1366
  _notify(
1366
- f"🔄 Rebalance: {label} closed LP + repaid ${repaid_usd:.2f} "
1367
- f"(token-matched, health was {pct}%)"
1367
+ f"🔄 Rebalance {label}: closed LP and repaid ${repaid_usd:.2f} "
1368
+ f"from freed tokens directly (no swap needed "
1369
+ f"freed USDC matched debt USDC). Health was {pct}%."
1368
1370
  )
1369
1371
  return result
1370
1372
 
@@ -1446,8 +1448,9 @@ def run_tick(
1446
1448
  total_repaid = repaid_usd + second_repay
1447
1449
  result["action"] = f"repaid ${total_repaid:.2f} (LP de-lever)"
1448
1450
  _notify(
1449
- f"🔄 Rebalance: {label} closed LP + repaid ${total_repaid:.2f} "
1450
- f"(health was {pct}%)"
1451
+ f"🔄 Rebalance {label}: closed LP, swapped freed tokens to "
1452
+ f"USDC, and repaid ${total_repaid:.2f} total. "
1453
+ f"Health was {pct}%."
1451
1454
  )
1452
1455
  else:
1453
1456
  result["warning"] = f"shortfall repay failed: {r2.stderr[:200]}"
@@ -1478,7 +1481,7 @@ def run_tick(
1478
1481
  if r.returncode == 0:
1479
1482
  cooldown_file.write_text(str(int(time.time())))
1480
1483
  result["action"] = f"repaid ${repay_amt:.2f}"
1481
- _notify(f"🔄 Rebalance: {label} repaid ${repay_amt:.2f} USDC (health was {pct}%)")
1484
+ _notify(f"🔄 {label}: repaid ${repay_amt:.2f} USDC directly from raw balance (health was {pct}% — no swap needed)")
1482
1485
  else:
1483
1486
  result["error"] = f"repay failed: {r.stderr[:200]}"
1484
1487
  except Exception as e:
@@ -1565,14 +1568,14 @@ def run_tick(
1565
1568
  )
1566
1569
  if sr.returncode == 0:
1567
1570
  result["action"] = f"borrowed ${borrow_amt:.2f}, swapped to {swap_target}"
1568
- _notify(f"🔄 Rebalance: {label} borrowed ${borrow_amt:.2f} USDC swapped to {swap_target} (health was {pct}%)")
1571
+ _notify(f"🔄 ⬆️ {label}: leveraging up — borrowed ${borrow_amt:.2f} USDC and swapped to {swap_target} (health was {pct}%, adding leverage for yield)")
1569
1572
  else:
1570
1573
  result["warning"] = f"swap to {swap_target} failed after borrow: {sr.stderr[:200]}"
1571
1574
  except Exception as e:
1572
1575
  result["error"] = f"borrow+swap error: {e}"
1573
1576
  else:
1574
1577
  result["action"] = f"borrowed ${borrow_amt:.2f} USDC (defisims autofarm deploys)"
1575
- _notify(f"🔄 Rebalance: {label} borrowed ${borrow_amt:.2f} USDC (health was {pct}%) defisims deploys")
1578
+ _notify(f"🔄 ⬆️ {label}: leveraging up — borrowed ${borrow_amt:.2f} USDC (health was {pct}%, defisims autofarm will deploy into LP)")
1576
1579
 
1577
1580
  cooldown_file.write_text(str(int(time.time())))
1578
1581
  return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.11.0
3
+ Version: 0.11.2
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.0 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.2 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.0"
7
+ version = "0.11.2"
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"
@@ -262,3 +262,65 @@ def test_match_pool_cfg_pair_only_unchanged_for_unique_pair():
262
262
  # Legacy 2-arg call still works for callers/tests that don't pass tickSpacing/version.
263
263
  cfg = dp.AERODROME_POOLS["aero-cbbtc-200"]
264
264
  assert dp._aero_match_pool_cfg(cfg["token0"], cfg["token1"]) is cfg
265
+
266
+
267
+ # ─────────────────────────── FIX 3: EURC display vs EUROC account symbol ─────
268
+
269
+ def test_eurc_display_symbol_reads_euroc_account_balance():
270
+ calls = []
271
+
272
+ class _AccountFns:
273
+ def getBalance(self, sym_b32):
274
+ calls.append(("balance", sym_b32.rstrip(b"\x00").decode()))
275
+ return _Call(lambda: 123, ())
276
+
277
+ def getTotalIntentAmount(self, sym_b32):
278
+ calls.append(("intent", sym_b32.rstrip(b"\x00").decode()))
279
+ return _Call(lambda: 0, ())
280
+
281
+ class _Account:
282
+ functions = _AccountFns()
283
+
284
+ assert dp._account_asset_symbol("EURC") == "EUROC"
285
+ assert dp._aero_in_account_balance(_Account(), "EURC") == 123
286
+ assert calls == [("balance", "EUROC"), ("intent", "EUROC")]
287
+
288
+
289
+ # ─────────────── FIX 4: EURC/EUROC sweep-separation dedup (_use_all_available) ─
290
+ # _aero_use_all_available split its inventory into the two pool-token balances vs
291
+ # the non-pool "sweep" assets with a raw string compare (sym == symbol1). For an
292
+ # ETH/EURC pool the account holds the EURC leg under its EUROC alias, so
293
+ # "EUROC" != "EURC" dropped that real pool-token balance into the sweep bucket —
294
+ # which --execute would then swap away before minting, while it was ALSO counted as
295
+ # the pool leg. The fix normalizes both sides via _account_asset_symbol.
296
+
297
+ def test_separate_pool_and_sweeps_dedups_eurc_alias():
298
+ # symbol1 == "EURC", but the same balance is also keyed under the account alias
299
+ # "EUROC". Neither may land in sweeps, and the pool leg is counted exactly once.
300
+ bal = 820_000_000 # ~820 EURC (6 decimals)
301
+ valuable = {"ETH": 5 * 10**17, "EURC": bal, "EUROC": bal, "USDC": 50_000_000}
302
+ pool0, pool1, sweeps = dp._aero_separate_pool_and_sweeps(valuable, "ETH", "EURC")
303
+ assert "EURC" not in sweeps
304
+ assert "EUROC" not in sweeps
305
+ assert pool0 == 5 * 10**17
306
+ assert pool1 == bal # counted once, not doubled
307
+ assert sweeps == {"USDC": 50_000_000} # only the genuine foreign asset sweeps
308
+
309
+
310
+ def test_separate_pool_and_sweeps_pool_token_only_under_alias():
311
+ # Even when the EURC leg is present ONLY under its EUROC account alias, it is
312
+ # attributed to the pool leg, never swept.
313
+ bal = 100_000_000
314
+ pool0, pool1, sweeps = dp._aero_separate_pool_and_sweeps(
315
+ {"ETH": 10**18, "EUROC": bal}, "ETH", "EURC")
316
+ assert pool1 == bal
317
+ assert sweeps == {}
318
+
319
+
320
+ def test_separate_pool_and_sweeps_no_alias_normal_split():
321
+ # No alias involved: genuine non-pool assets sweep, pool tokens don't.
322
+ valuable = {"WETH": 10**18, "USDC": 2_000_000, "AERO": 999, "cbBTC": 7}
323
+ pool0, pool1, sweeps = dp._aero_separate_pool_and_sweeps(valuable, "WETH", "USDC")
324
+ assert pool0 == 10**18
325
+ assert pool1 == 2_000_000
326
+ assert sweeps == {"AERO": 999, "cbBTC": 7}
@@ -141,3 +141,27 @@ def test_price_route_excludes_rfq_dexs(monkeypatch):
141
141
  excluded = captured["params"]["excludeDEXS"]
142
142
  assert "AugustusRFQ" in excluded
143
143
  assert "ParaSwapLimitOrders" in excluded
144
+
145
+
146
+ def test_price_route_pins_include_contract_methods(monkeypatch):
147
+ """`_paraswap_price_route` must allowlist via includeContractMethods (not
148
+ blocklist via excludeContractMethods) so a future ParaSwap route type the facet
149
+ still can't decode is excluded by construction, matching PARASWAP_SUPPORTED_SELECTORS
150
+ (swapExactAmountIn / swapExactAmountInOnUniswapV3)."""
151
+ captured = {}
152
+
153
+ class _Resp:
154
+ @staticmethod
155
+ def json():
156
+ return {"priceRoute": {"destAmount": "1"}}
157
+
158
+ def fake_get(url, params=None, headers=None, timeout=None):
159
+ captured["params"] = params
160
+ return _Resp()
161
+
162
+ monkeypatch.setattr(dp.requests, "get", fake_get)
163
+ dp._paraswap_price_route(SRC, 18, DEST, 6, AMOUNT, PA)
164
+ params = captured["params"]
165
+ assert "excludeContractMethods" not in params
166
+ included = set(params["includeContractMethods"].split(","))
167
+ assert included == {"swapExactAmountIn", "swapExactAmountInOnUniswapV3"}
File without changes
File without changes
File without changes