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.
- {primecli-0.11.0 → primecli-0.11.2}/PKG-INFO +2 -2
- {primecli-0.11.0 → primecli-0.11.2}/README.md +1 -1
- {primecli-0.11.0 → primecli-0.11.2}/primecli/arbprime.py +44 -7
- {primecli-0.11.0 → primecli-0.11.2}/primecli/degenprime.py +144 -43
- {primecli-0.11.0 → primecli-0.11.2}/primecli/deltaprime.py +52 -9
- {primecli-0.11.0 → primecli-0.11.2}/primecli/health_monitor.py +18 -15
- {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.11.0 → primecli-0.11.2}/pyproject.toml +1 -1
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_aero_v3_collision_fixes.py +62 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_paraswap_requote.py +24 -0
- {primecli-0.11.0 → primecli-0.11.2}/LICENSE +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli/__init__.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli/_flowledger.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli/_wallets.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli/bridge.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/SOURCES.txt +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/setup.cfg +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_aero_range_and_swap_fallback.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_aero_rebalance.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_bridge.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_flowledger_transferred_amount.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_gas_limit.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_gas_pricing.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_health_meter.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_health_monitor.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.11.0 → primecli-0.11.2}/tests/test_redstone_encoding.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
705
|
-
|
|
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
|
|
709
|
-
|
|
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
|
-
|
|
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-
|
|
31
|
-
degenprime aero-add-liquidity --pool weth-
|
|
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
|
|
494
|
-
|
|
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
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
4387
|
-
|
|
4388
|
-
pool1_bal_wei =
|
|
4389
|
-
|
|
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
|
-
|
|
4539
|
-
the desired liquidity amounts in
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
6268
|
-
print("Usage: degenprime aero-add-liquidity --pool
|
|
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
|
|
704
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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}:
|
|
1352
|
-
f"
|
|
1353
|
-
f"
|
|
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
|
|
1367
|
-
f"(
|
|
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
|
|
1450
|
-
f"
|
|
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"🔄
|
|
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"🔄
|
|
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"🔄
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|