primecli 0.7.5__tar.gz → 0.8.0__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.7.5 → primecli-0.8.0}/PKG-INFO +2 -2
- {primecli-0.7.5 → primecli-0.8.0}/README.md +1 -1
- {primecli-0.7.5 → primecli-0.8.0}/primecli/degenprime.py +275 -107
- {primecli-0.7.5 → primecli-0.8.0}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.7.5 → primecli-0.8.0}/pyproject.toml +1 -1
- {primecli-0.7.5 → primecli-0.8.0}/LICENSE +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli/__init__.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli/arbprime.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli/deltaprime.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli/health_monitor.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli.egg-info/SOURCES.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/setup.cfg +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_gas_limit.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_gas_pricing.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_health_meter.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_health_monitor.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/tests/test_redstone_encoding.py +0 -0
- {primecli-0.7.5 → primecli-0.8.0}/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.
|
|
3
|
+
Version: 0.8.0
|
|
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.
|
|
50
|
+
**Current version:** 0.8.0 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.
|
|
19
|
+
**Current version:** 0.8.0 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
|
|
|
@@ -76,8 +76,9 @@ asset (--from), and repays the old debt. --from is the existing debt being
|
|
|
76
76
|
refinanced; --to is the new debt taken on. RedStone-gated on execute.
|
|
77
77
|
|
|
78
78
|
aerodrome-positions is read-only: lists each Aerodrome Slipstream (CL) NFT position the
|
|
79
|
-
Degen Account owns/stakes, showing token0/token1/tick range/liquidity
|
|
80
|
-
getOwnedStakedAerodromeTokenIds
|
|
79
|
+
Degen Account owns/stakes, showing token0/token1/tick range/liquidity. Enumerates tokenIds
|
|
80
|
+
via getOwnedStakedAerodromeTokenIds, then reads each from the Aerodrome NPM.positions()
|
|
81
|
+
view (correct for staked NFTs, which the simplified facet view reports as 0 liquidity).
|
|
81
82
|
|
|
82
83
|
aero-add-liquidity / aero-remove-liquidity / aero-collect-fees provide write paths to
|
|
83
84
|
the Aerodrome Slipstream NonfungiblePositionManager through the Degen Account's
|
|
@@ -212,11 +213,11 @@ AERODROME_NPM = "0x827922686190790b37229fd06084350E74485b72"
|
|
|
212
213
|
# ca15558b (write, takes DecreaseLiquidityParams tuple 5-field — matches decreaseLiquidity)
|
|
213
214
|
# 92b5a47e (write, takes uint256 tokenId, checks position exists — burn/collect)
|
|
214
215
|
# 46daca2c (write, onlyOwnerOrLiquidator — emergency withdrawal)
|
|
215
|
-
AERODROME_SEL_MINT = bytes.fromhex("
|
|
216
|
+
AERODROME_SEL_MINT = bytes.fromhex("f32f1e56") # mintAndStakeLiquidityAerodrome
|
|
216
217
|
AERODROME_SEL_INCREASE = bytes.fromhex("2c710777") # inferred: increaseLiquidity
|
|
217
|
-
AERODROME_SEL_DECREASE = bytes.fromhex("
|
|
218
|
-
AERODROME_SEL_BURN = bytes.fromhex("
|
|
219
|
-
AERODROME_SEL_COLLECT = bytes.fromhex("
|
|
218
|
+
AERODROME_SEL_DECREASE = bytes.fromhex("cb16b6c6") # decreaseAerodromeLiquidity
|
|
219
|
+
AERODROME_SEL_BURN = bytes.fromhex("27bed82e") # batchRemoveStakedLiquidityAerodrome
|
|
220
|
+
AERODROME_SEL_COLLECT = bytes.fromhex("887e4b7e") # collectAerodromeFees
|
|
220
221
|
|
|
221
222
|
# Whitelisted Aerodrome CL pools exposed as tool keys — the authoritative set of
|
|
222
223
|
# ~31 DegenPrime-supported SlipStream pools. Every entry was verified on-chain
|
|
@@ -824,6 +825,9 @@ PRIME_ACCOUNT_ABI = [
|
|
|
824
825
|
{"inputs": [{"name": "tokenId", "type": "uint256"}],
|
|
825
826
|
"name": "collectAerodromeFees", "outputs": [],
|
|
826
827
|
"stateMutability": "nonpayable", "type": "function"},
|
|
828
|
+
# batchRemoveStakedLiquidityAerodrome(uint256[]) — selector 0x27bed82e
|
|
829
|
+
# (== AERODROME_SEL_BURN, verified). Unstakes + closes the listed positions.
|
|
830
|
+
{"inputs":[{"internalType":"uint256[]","name":"tokenIds","type":"uint256[]"}],"name":"batchRemoveStakedLiquidityAerodrome","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
827
831
|
]
|
|
828
832
|
|
|
829
833
|
# TokenManager ABI - minimal subset for symbol/decimals lookups + supported tokens
|
|
@@ -834,7 +838,8 @@ TOKEN_MANAGER_ABI = [
|
|
|
834
838
|
"outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
|
|
835
839
|
{"inputs": [{"name": "_address", "type": "address"}], "name": "tokenAddressToSymbol",
|
|
836
840
|
"outputs": [{"type": "bytes32"}], "stateMutability": "view", "type": "function"},
|
|
837
|
-
{"inputs": [{"name": "_symbol", "type": "bytes32"}
|
|
841
|
+
{"inputs": [{"name": "_symbol", "type": "bytes32"},
|
|
842
|
+
{"name": "_allowInactive", "type": "bool"}], "name": "getAssetAddress",
|
|
838
843
|
"outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
|
|
839
844
|
{"inputs": [], "name": "getSupportedTokensAddresses",
|
|
840
845
|
"outputs": [{"type": "address[]"}], "stateMutability": "view", "type": "function"},
|
|
@@ -947,6 +952,28 @@ def get_prime_account(w3, owner: str) -> str:
|
|
|
947
952
|
def asset_b32(symbol: str) -> bytes:
|
|
948
953
|
return symbol.encode().ljust(32, b"\x00")
|
|
949
954
|
|
|
955
|
+
def fmt_token_amount(raw: int, decimals: int) -> str:
|
|
956
|
+
"""Human token amount that never misleadingly rounds a dust balance UP.
|
|
957
|
+
|
|
958
|
+
Plain f"{x:,.6f}" turns 9.41e-7 into "0.000001" — visually a larger,
|
|
959
|
+
round number than the true value, which is exactly what over-requested a
|
|
960
|
+
dust balance and reverted a mint after burning gas (2026-06-14). For any
|
|
961
|
+
nonzero value that 6-dp formatting would round to zero or up to its own
|
|
962
|
+
last place, append the raw base units so the real size is unambiguous.
|
|
963
|
+
Normal/large balances keep the familiar grouped 6-dp display."""
|
|
964
|
+
if raw == 0:
|
|
965
|
+
return "0"
|
|
966
|
+
amt = Decimal(raw) / (Decimal(10) ** int(decimals))
|
|
967
|
+
rounded6 = amt.quantize(Decimal("0.000001"))
|
|
968
|
+
# Switch to sci-notation + raw wei only for genuinely small balances that 6-dp
|
|
969
|
+
# formatting would misrepresent: rounds to 0 (looks like nothing), or rounds UP
|
|
970
|
+
# below the 1e-4 dust line (e.g. 9.41e-7 -> 0.000001, the over-request trap).
|
|
971
|
+
# Larger values keep the familiar grouped 6-dp display even if the last place
|
|
972
|
+
# rounds — there a round-up isn't misleading and sci-notation would be noise.
|
|
973
|
+
if rounded6 == 0 or (rounded6 > amt and amt < Decimal("0.0001")):
|
|
974
|
+
return f"{amt:.3e} ({raw} wei)"
|
|
975
|
+
return f"{amt:,.6f}"
|
|
976
|
+
|
|
950
977
|
def pool_to_asset_symbol(pool_name: str) -> str:
|
|
951
978
|
"""Pool key -> on-chain bytes32 asset symbol (the contracts use 'ETH', not 'WETH')."""
|
|
952
979
|
return POOLS[pool_name]["symbol"]
|
|
@@ -976,7 +1003,7 @@ def _asset_meta(w3, symbol: str):
|
|
|
976
1003
|
return _asset_meta_cache[symbol]
|
|
977
1004
|
try:
|
|
978
1005
|
tm = get_token_manager(w3)
|
|
979
|
-
addr = tm.functions.getAssetAddress(asset_b32(symbol)).call()
|
|
1006
|
+
addr = tm.functions.getAssetAddress(asset_b32(symbol), True).call()
|
|
980
1007
|
if int(addr, 16) == 0:
|
|
981
1008
|
_asset_meta_cache[symbol] = (None, 18)
|
|
982
1009
|
return _asset_meta_cache[symbol]
|
|
@@ -2180,7 +2207,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2180
2207
|
if pool_deposits:
|
|
2181
2208
|
print(" Pool Deposits (Diamond Hands):")
|
|
2182
2209
|
for r in pool_deposits:
|
|
2183
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2210
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}")
|
|
2184
2211
|
return
|
|
2185
2212
|
|
|
2186
2213
|
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
@@ -2228,7 +2255,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2228
2255
|
for r in supplied:
|
|
2229
2256
|
usd = solvency["prices"].get(r["symbol"])
|
|
2230
2257
|
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
2231
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2258
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}{usd_str}")
|
|
2232
2259
|
else:
|
|
2233
2260
|
print(" (none)")
|
|
2234
2261
|
|
|
@@ -2237,7 +2264,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2237
2264
|
for r in borrowed:
|
|
2238
2265
|
usd = solvency["prices"].get(r["symbol"])
|
|
2239
2266
|
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
2240
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2267
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}{usd_str}")
|
|
2241
2268
|
else:
|
|
2242
2269
|
print(" (none)")
|
|
2243
2270
|
|
|
@@ -2246,7 +2273,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2246
2273
|
for r in pool_deposits:
|
|
2247
2274
|
usd = solvency["prices"].get(r["symbol"])
|
|
2248
2275
|
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
2249
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2276
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}{usd_str}")
|
|
2250
2277
|
|
|
2251
2278
|
if solvency["error"] is None:
|
|
2252
2279
|
# A solvency view can come back None even with no error (e.g. a multicall leg that
|
|
@@ -2275,7 +2302,14 @@ def cmd_summary(as_json: bool = False):
|
|
|
2275
2302
|
print(f" 0%=liquidation 50%=half borrowing power used 100%=no debt")
|
|
2276
2303
|
else:
|
|
2277
2304
|
print(f" Health (0-100%): N/A ({hp['error']})")
|
|
2278
|
-
|
|
2305
|
+
# An account with no debt cannot be liquidated. isSolvent() can come back
|
|
2306
|
+
# None on a no-debt account (empty multicall leg), which used to render a
|
|
2307
|
+
# misleading "NO - liquidatable" despite ratio >1000 and ~$0 debt. Treat
|
|
2308
|
+
# negligible debt (or a >1000 ratio, surfaced as ratio=None) as solvent.
|
|
2309
|
+
negligible_debt = (solvency["debt"] is None or solvency["debt"] < 0.01
|
|
2310
|
+
or solvency["ratio"] is None)
|
|
2311
|
+
is_solvent = bool(solvency["solvent"]) or negligible_debt
|
|
2312
|
+
print(f" Solvent: {'yes' if is_solvent else 'NO - liquidatable'}")
|
|
2279
2313
|
else:
|
|
2280
2314
|
print(f" Health/solvency: RedStone fetch/call failed ({solvency['error']}); showing balances only")
|
|
2281
2315
|
|
|
@@ -3065,8 +3099,9 @@ def cmd_cancel_withdrawal(pool_name: str, index: int, execute: bool = False):
|
|
|
3065
3099
|
|
|
3066
3100
|
def cmd_aerodrome_positions():
|
|
3067
3101
|
"""Read-only: list every Aerodrome Slipstream NFT position the Degen Account
|
|
3068
|
-
owns/stakes, showing token pair, tick range, and liquidity
|
|
3069
|
-
getOwnedStakedAerodromeTokenIds
|
|
3102
|
+
owns/stakes, showing token pair, tick range, and liquidity. Enumerates tokenIds
|
|
3103
|
+
via getOwnedStakedAerodromeTokenIds, then reads each from NPM.positions() (the
|
|
3104
|
+
simplified facet view reports liquidity=0 + garbage ticks for staked NFTs)."""
|
|
3070
3105
|
w3 = get_w3()
|
|
3071
3106
|
acct = get_account()
|
|
3072
3107
|
print(f"Wallet: {acct.address}")
|
|
@@ -3086,42 +3121,44 @@ def cmd_aerodrome_positions():
|
|
|
3086
3121
|
return
|
|
3087
3122
|
print(f" {len(ids)} Aerodrome NFT position(s):")
|
|
3088
3123
|
|
|
3089
|
-
#
|
|
3090
|
-
#
|
|
3091
|
-
#
|
|
3092
|
-
#
|
|
3093
|
-
pos_legs = []
|
|
3124
|
+
# Read each position straight from NPM.positions(). The account's enumerated
|
|
3125
|
+
# tokenIds include STAKED positions whose NFT now belongs to the gauge, and the
|
|
3126
|
+
# facet's getPositionCompositionSimplified reports liquidity=0 + garbage ticks
|
|
3127
|
+
# for those. NPM.positions() returns the real struct for any holder.
|
|
3094
3128
|
for tid in ids:
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
results = multicall(w3, pos_legs)
|
|
3099
|
-
except Exception:
|
|
3100
|
-
results = [(False, b"")] * len(ids)
|
|
3101
|
-
|
|
3102
|
-
for tid, (ok, rd) in zip(ids, results):
|
|
3103
|
-
if not ok or not rd:
|
|
3104
|
-
print(f" [{tid}] composition unavailable")
|
|
3129
|
+
pos = _aero_read_position(w3, tid)
|
|
3130
|
+
if pos is None:
|
|
3131
|
+
print(f" [{tid}] position read failed")
|
|
3105
3132
|
continue
|
|
3106
|
-
|
|
3107
|
-
token0, token1, tick_data, liq = w3.codec.decode(
|
|
3108
|
-
["address", "address", "uint256", "uint256"], rd)
|
|
3109
|
-
except Exception:
|
|
3110
|
-
print(f" [{tid}] composition decode failed")
|
|
3111
|
-
continue
|
|
3112
|
-
# Decode tickData: upper 128 bits = tickLower (int24), lower 128 bits = tickUpper (int24)
|
|
3113
|
-
tick_lower = _int24_from_hi128(tick_data)
|
|
3114
|
-
tick_upper = _int24_from_lo128(tick_data)
|
|
3115
|
-
# Resolve token symbols
|
|
3133
|
+
token0, token1, tick_lower, tick_upper, liq = pos
|
|
3116
3134
|
sym0 = _resolve_token_symbol(w3, token0)
|
|
3117
3135
|
sym1 = _resolve_token_symbol(w3, token1)
|
|
3118
|
-
# Human-readable tick range → price range
|
|
3119
3136
|
price_lower = 1.0001 ** tick_lower
|
|
3120
3137
|
price_upper = 1.0001 ** tick_upper
|
|
3121
3138
|
print(f" [{tid}] {sym0}/{sym1} ticks=[{tick_lower}, {tick_upper}]"
|
|
3122
3139
|
f" liq={liq} price_range=[{price_lower:.4f}, {price_upper:.4f}]")
|
|
3123
3140
|
print(" Manage on Aerodrome UI: https://aerodrome.finance/positions")
|
|
3124
3141
|
|
|
3142
|
+
def _aero_read_position(w3, token_id: int):
|
|
3143
|
+
"""Authoritative read of an Aerodrome Slipstream position via NPM.positions().
|
|
3144
|
+
|
|
3145
|
+
getPositionCompositionSimplified is wrong for STAKED positions: once an NFT is
|
|
3146
|
+
staked its owner becomes the gauge, and the simplified view returns liquidity=0
|
|
3147
|
+
with a tickData word that is not a tick packing at all (it decodes to garbage,
|
|
3148
|
+
e.g. [0, -3984902] for the live tokenId 71997868). NPM.positions(tokenId) returns
|
|
3149
|
+
the real struct regardless of who holds the NFT — token0/token1, tickLower,
|
|
3150
|
+
tickUpper, and liquidity. Returns (token0, token1, tickLower, tickUpper, liquidity)
|
|
3151
|
+
or None if the read fails."""
|
|
3152
|
+
try:
|
|
3153
|
+
npm = w3.eth.contract(address=Web3.to_checksum_address(AERODROME_NPM),
|
|
3154
|
+
abi=AERODROME_NPM_ABI)
|
|
3155
|
+
p = npm.functions.positions(token_id).call()
|
|
3156
|
+
except Exception:
|
|
3157
|
+
return None
|
|
3158
|
+
# positions() layout: nonce, operator, token0, token1, tickSpacing,
|
|
3159
|
+
# tickLower, tickUpper, liquidity, ...
|
|
3160
|
+
return (p[2], p[3], p[5], p[6], p[7])
|
|
3161
|
+
|
|
3125
3162
|
def _int24_from_hi128(val: int) -> int:
|
|
3126
3163
|
"""Extract int24 from the upper 128 bits of a uint256, sign-extending."""
|
|
3127
3164
|
raw = (val >> 128) & 0xFFFFFF
|
|
@@ -3162,30 +3199,112 @@ def _resolve_token_symbol(w3, addr: str) -> str:
|
|
|
3162
3199
|
|
|
3163
3200
|
# ─── Aerodrome Write Commands ────────────────────────────────────────────────
|
|
3164
3201
|
|
|
3165
|
-
# Helper:
|
|
3166
|
-
def
|
|
3202
|
+
# Helper: get Aerodrome CL pool address from the factory's getPool (authoritative).
|
|
3203
|
+
def _aero_pool_address(pool_cfg: dict) -> str:
|
|
3204
|
+
"""Resolve the pool address via the factory's getPool()."""
|
|
3205
|
+
try:
|
|
3206
|
+
w3_local = Web3(Web3.HTTPProvider(BASE_RPC))
|
|
3207
|
+
factory = Web3.to_checksum_address("0x5e7BB104d84c7CB9B682AaC2F3d509f5F406809A")
|
|
3208
|
+
import json
|
|
3209
|
+
f_abi = json.loads('[{"inputs":[{"name":"","type":"address"},{"name":"","type":"address"},{"name":"","type":"int24"}],"name":"getPool","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"}]')
|
|
3210
|
+
factory_c = w3_local.eth.contract(address=factory, abi=f_abi)
|
|
3211
|
+
t0 = Web3.to_checksum_address(pool_cfg["token0"])
|
|
3212
|
+
t1 = Web3.to_checksum_address(pool_cfg["token1"])
|
|
3213
|
+
pool = factory_c.functions.getPool(t0, t1, pool_cfg["tickSpacing"]).call()
|
|
3214
|
+
if pool == "0x0000000000000000000000000000000000000000":
|
|
3215
|
+
raise ValueError("Pool does not exist on Aerodrome")
|
|
3216
|
+
return pool
|
|
3217
|
+
except Exception as e:
|
|
3218
|
+
raise RuntimeError(f"Cannot resolve Aerodrome pool: {e}")
|
|
3219
|
+
|
|
3220
|
+
SLOT0_ABI = json.dumps([{"inputs":[],"name":"slot0","outputs":[
|
|
3221
|
+
{"internalType":"uint160","name":"sqrtPriceX96","type":"uint160"},
|
|
3222
|
+
{"internalType":"int24","name":"tick","type":"int24"}],
|
|
3223
|
+
"stateMutability":"view","type":"function"}])
|
|
3224
|
+
|
|
3225
|
+
# Helper: build the 14-field arg tuple for the Aerodrome mint+stake facet fn
|
|
3226
|
+
# (selector 0xf32f1e56). Layout verified byte-exact against the successful
|
|
3227
|
+
# on-chain mint 0x1723377a... (ZORA/USDC, Base, 2026-06-14):
|
|
3228
|
+
# token0, token1, tickLower, tickUpper, tickSpacing,
|
|
3229
|
+
# amount0Desired(wei), amount1Desired(wei), amount0Min(wei), amount1Min(wei),
|
|
3230
|
+
# uint256 const=300, int24 currentTick, 0, 0, 0
|
|
3231
|
+
# Both amounts are NATIVE/wei units; there is NO recipient or unix-deadline
|
|
3232
|
+
# field, tickSpacing IS part of the args, and the last tick field is the live
|
|
3233
|
+
# pool tick (not sqrtPriceX96). The three trailing zero words are
|
|
3234
|
+
# sqrtPriceX96=0 / borrow-if-needed bools = false (all zero either way).
|
|
3235
|
+
def _aero_in_account_balance(account, symbol: str) -> int:
|
|
3236
|
+
"""In-account spendable balance (base units) of `symbol` on the Degen Account.
|
|
3237
|
+
|
|
3238
|
+
The mint+stake facet pulls token0/token1 from the account's own holdings, the
|
|
3239
|
+
same balance getBalance(bytes32) reports (verified equal to ERC20.balanceOf on
|
|
3240
|
+
the account 2026-06-14). Subtract any pending withdrawal-intent lock so we never
|
|
3241
|
+
treat reserved funds as available. Returns 0 if the view reverts."""
|
|
3242
|
+
try:
|
|
3243
|
+
bal = account.functions.getBalance(asset_b32(symbol)).call()
|
|
3244
|
+
except Exception:
|
|
3245
|
+
return 0
|
|
3246
|
+
try:
|
|
3247
|
+
locked = account.functions.getTotalIntentAmount(asset_b32(symbol)).call()
|
|
3248
|
+
except Exception:
|
|
3249
|
+
locked = 0
|
|
3250
|
+
return bal - locked if bal > locked else 0
|
|
3251
|
+
|
|
3252
|
+
def _aero_cap_to_balance(account, pool_cfg: dict, amt0_wei: int, amt1_wei: int) -> tuple:
|
|
3253
|
+
"""Cap each requested amount to what the account actually holds, minus a 1-wei
|
|
3254
|
+
margin so display round-up can never push the request past the real balance.
|
|
3255
|
+
Returns (amt0_capped, amt1_capped, notes) where notes lists human cap messages."""
|
|
3256
|
+
notes = []
|
|
3257
|
+
sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
|
|
3258
|
+
dec0, dec1 = pool_cfg["decimals0"], pool_cfg["decimals1"]
|
|
3259
|
+
for amt, sym, dec, idx in ((amt0_wei, sym0, dec0, 0), (amt1_wei, sym1, dec1, 1)):
|
|
3260
|
+
if amt <= 0:
|
|
3261
|
+
continue
|
|
3262
|
+
avail = _aero_in_account_balance(account, sym)
|
|
3263
|
+
safe = avail - 1 if avail > 0 else 0 # 1-wei margin vs rounding overshoot
|
|
3264
|
+
if amt > safe:
|
|
3265
|
+
capped = safe if safe > 0 else 0
|
|
3266
|
+
notes.append((idx, capped,
|
|
3267
|
+
f"Capped {sym} to {fmt_token_amount(capped, dec)} "
|
|
3268
|
+
f"(on-chain balance {fmt_token_amount(avail, dec)})"))
|
|
3269
|
+
amt0_out, amt1_out = amt0_wei, amt1_wei
|
|
3270
|
+
for idx, capped, _msg in notes:
|
|
3271
|
+
if idx == 0:
|
|
3272
|
+
amt0_out = capped
|
|
3273
|
+
else:
|
|
3274
|
+
amt1_out = capped
|
|
3275
|
+
return amt0_out, amt1_out, [m for _i, _c, m in notes]
|
|
3276
|
+
|
|
3277
|
+
def _aero_simulate_mint(w3, from_addr: str, account_addr: str, calldata: bytes) -> tuple:
|
|
3278
|
+
"""eth_call-simulate the mint+stake before broadcasting. Returns (ok, info):
|
|
3279
|
+
ok=True + would-be tokenId on success, ok=False + revert detail on failure.
|
|
3280
|
+
Read-only — never signs or sends."""
|
|
3281
|
+
try:
|
|
3282
|
+
ret = w3.eth.call({"from": from_addr, "to": account_addr,
|
|
3283
|
+
"data": "0x" + calldata.hex()})
|
|
3284
|
+
except Exception as e:
|
|
3285
|
+
return False, f"{type(e).__name__}: {str(e)[:200]}"
|
|
3286
|
+
token_id = int.from_bytes(bytes(ret)[:32], "big") if len(ret) >= 32 else None
|
|
3287
|
+
return True, token_id
|
|
3288
|
+
|
|
3289
|
+
def _aero_mint_params(pool_cfg: dict, amount0_wei: int, amount1_wei: int,
|
|
3167
3290
|
tick_lower: int, tick_upper: int,
|
|
3168
|
-
|
|
3169
|
-
"""Build MintParams=(token0,token1,tickSpacing,tickLower,tickUpper,
|
|
3170
|
-
amount0Desired,amount1Desired,amount0Min,amount1Min,recipient,deadline,
|
|
3171
|
-
sqrtPriceX96). sqrtPriceX96=0 means the NPM uses the pool's current price."""
|
|
3291
|
+
current_tick: int, slippage_pct: float) -> tuple:
|
|
3172
3292
|
slippage = Decimal(str(slippage_pct)) / Decimal(100)
|
|
3173
|
-
amount0_min = int(Decimal(str(
|
|
3174
|
-
amount1_min = int(Decimal(str(
|
|
3175
|
-
deadline = int(time.time()) + 1800 # 30 minutes
|
|
3293
|
+
amount0_min = int(Decimal(str(amount0_wei)) * (Decimal(1) - slippage))
|
|
3294
|
+
amount1_min = int(Decimal(str(amount1_wei)) * (Decimal(1) - slippage))
|
|
3176
3295
|
return (
|
|
3177
3296
|
Web3.to_checksum_address(pool_cfg["token0"]),
|
|
3178
3297
|
Web3.to_checksum_address(pool_cfg["token1"]),
|
|
3179
|
-
pool_cfg["tickSpacing"],
|
|
3180
3298
|
tick_lower,
|
|
3181
3299
|
tick_upper,
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3300
|
+
pool_cfg["tickSpacing"],
|
|
3301
|
+
amount0_wei, # amount0Desired (wei)
|
|
3302
|
+
amount1_wei, # amount1Desired (wei)
|
|
3303
|
+
amount0_min, # amount0Min (wei)
|
|
3304
|
+
amount1_min, # amount1Min (wei)
|
|
3305
|
+
300, # word9 constant (frontend passes 300)
|
|
3306
|
+
int(current_tick), # word10: live pool tick
|
|
3307
|
+
0, 0, 0, # word11-13: zero (sqrtPriceX96=0 / bools false)
|
|
3189
3308
|
)
|
|
3190
3309
|
|
|
3191
3310
|
# Helper: build DecreaseLiquidityParams tuple (5 fields).
|
|
@@ -3196,26 +3315,28 @@ def _aero_decrease_params(token_id: int, liquidity: int,
|
|
|
3196
3315
|
|
|
3197
3316
|
# Helper: compute tick range around a desired centre price.
|
|
3198
3317
|
def _aero_tick_range(tick_spacing: int, centre_price: float = None,
|
|
3199
|
-
width_pct: float = 2.0) -> tuple:
|
|
3200
|
-
"""Return (tickLower, tickUpper) for
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3318
|
+
width_pct: float = 2.0, pool_tick: int = None) -> tuple:
|
|
3319
|
+
"""Return (tickLower, tickUpper) for +/-width_pct around centre.
|
|
3320
|
+
Priority: pool_tick > centre_price > full range."""
|
|
3321
|
+
import math
|
|
3322
|
+
MIN_TICK, MAX_TICK = -887272, 887272
|
|
3323
|
+
|
|
3324
|
+
if pool_tick is not None:
|
|
3325
|
+
tick_delta = int(abs(math.log(1.0 + width_pct / 100.0) / math.log(1.0001))) + 1
|
|
3326
|
+
raw_lower = pool_tick - tick_delta
|
|
3327
|
+
raw_upper = pool_tick + tick_delta
|
|
3328
|
+
elif centre_price is not None and centre_price > 0:
|
|
3329
|
+
lower_price = centre_price * max(1e-12, 1.0 - width_pct / 100.0)
|
|
3330
|
+
upper_price = centre_price * (1.0 + width_pct / 100.0)
|
|
3331
|
+
raw_lower = math.log(lower_price) / math.log(1.0001)
|
|
3332
|
+
raw_upper = math.log(upper_price) / math.log(1.0001)
|
|
3333
|
+
else:
|
|
3208
3334
|
t_lower = (MIN_TICK // tick_spacing) * tick_spacing
|
|
3209
3335
|
t_upper = (MAX_TICK // tick_spacing) * tick_spacing
|
|
3210
3336
|
return (t_lower, t_upper)
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
half_width_ticks = int(centre_tick * width_pct / 100.0)
|
|
3215
|
-
tick_lower = ((centre_tick - half_width_ticks) // tick_spacing) * tick_spacing
|
|
3216
|
-
tick_upper = ((centre_tick + half_width_ticks) // tick_spacing) * tick_spacing
|
|
3217
|
-
# Clamp to valid range
|
|
3218
|
-
MIN_TICK, MAX_TICK = -887272, 887272
|
|
3337
|
+
|
|
3338
|
+
tick_lower = math.floor(raw_lower / tick_spacing) * tick_spacing
|
|
3339
|
+
tick_upper = math.ceil(raw_upper / tick_spacing) * tick_spacing
|
|
3219
3340
|
tick_lower = max(MIN_TICK, min(MAX_TICK, tick_lower))
|
|
3220
3341
|
tick_upper = max(MIN_TICK, min(MAX_TICK, tick_upper))
|
|
3221
3342
|
if tick_lower >= tick_upper:
|
|
@@ -3259,43 +3380,81 @@ def cmd_aero_add_liquidity(pool_key: str, amount0: float = None,
|
|
|
3259
3380
|
print("At least one of --amount-<token0> / --amount-<token1> must be > 0")
|
|
3260
3381
|
return
|
|
3261
3382
|
|
|
3262
|
-
#
|
|
3263
|
-
|
|
3383
|
+
# Auto-cap each side to the account's real in-account balance. The summary
|
|
3384
|
+
# display can round a dust balance UP, so a request matching the shown value
|
|
3385
|
+
# could exceed the true balance and revert AFTER burning gas (2026-06-14).
|
|
3386
|
+
amt0, amt1, cap_notes = _aero_cap_to_balance(account, pool_cfg, amt0, amt1)
|
|
3387
|
+
for note in cap_notes:
|
|
3388
|
+
print(f" {note}")
|
|
3389
|
+
if amt0 == 0 and amt1 == 0:
|
|
3390
|
+
print(" Nothing to deposit after capping to on-chain balance.")
|
|
3391
|
+
return
|
|
3392
|
+
|
|
3393
|
+
# Get pool's on-chain tick from slot0 for accurate range computation
|
|
3394
|
+
pool_tick = None
|
|
3264
3395
|
centre_price = None
|
|
3265
3396
|
try:
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3397
|
+
pool_addr = _aero_pool_address(pool_cfg)
|
|
3398
|
+
pool_abi = json.loads(SLOT0_ABI)
|
|
3399
|
+
pool_c = w3.eth.contract(address=pool_addr, abi=pool_abi)
|
|
3400
|
+
slot0 = pool_c.functions.slot0().call()
|
|
3401
|
+
pool_tick = slot0[1]
|
|
3402
|
+
print(f" Pool tick (on-chain): {pool_tick}")
|
|
3269
3403
|
except Exception:
|
|
3270
|
-
|
|
3404
|
+
# Fallback to KuCoin price
|
|
3405
|
+
price_sym = pool_cfg["symbol0"] + "-USDT"
|
|
3406
|
+
try:
|
|
3407
|
+
r = requests.get(f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={price_sym}", timeout=3)
|
|
3408
|
+
if r.status_code == 200 and r.json().get("code") == "200000":
|
|
3409
|
+
centre_price = float(r.json()["data"]["price"])
|
|
3410
|
+
except Exception:
|
|
3411
|
+
pass
|
|
3271
3412
|
|
|
3272
|
-
tick_lower, tick_upper = _aero_tick_range(pool_cfg["tickSpacing"], centre_price, width_pct)
|
|
3413
|
+
tick_lower, tick_upper = _aero_tick_range(pool_cfg["tickSpacing"], centre_price, width_pct, pool_tick)
|
|
3273
3414
|
params = _aero_mint_params(pool_cfg, amt0, amt1, tick_lower, tick_upper,
|
|
3274
|
-
|
|
3415
|
+
pool_tick if pool_tick is not None else (tick_lower + tick_upper) // 2,
|
|
3416
|
+
slippage_pct)
|
|
3275
3417
|
|
|
3276
3418
|
sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
|
|
3277
3419
|
print(f"Pool: {sym0}/{sym1} (tickSpacing={pool_cfg['tickSpacing']})")
|
|
3278
|
-
if
|
|
3420
|
+
if pool_tick is not None:
|
|
3421
|
+
print(f" Tick range: [{tick_lower}, {tick_upper}] (width: +/-{width_pct}%)")
|
|
3422
|
+
elif centre_price:
|
|
3279
3423
|
print(f" Current {sym0} price: ${centre_price:,.2f}")
|
|
3280
3424
|
print(f" Tick range: [{tick_lower}, {tick_upper}] → price [{1.0001**tick_lower:.4f}, {1.0001**tick_upper:.4f}]")
|
|
3281
3425
|
print(f" Width: ±{width_pct}%")
|
|
3282
3426
|
else:
|
|
3283
3427
|
print(f" Full-range position (no price data available)")
|
|
3284
|
-
print(f" {sym0}: {
|
|
3428
|
+
print(f" {sym0}: {fmt_token_amount(amt0, pool_cfg['decimals0'])} "
|
|
3429
|
+
f"{sym1}: {fmt_token_amount(amt1, pool_cfg['decimals1'])}")
|
|
3285
3430
|
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
# Build RedStone payload for solvency check
|
|
3431
|
+
# Build RedStone payload + final mint+stake calldata (selector 0xf32f1e56).
|
|
3432
|
+
# 14 flat args; amounts in native wei; tickSpacing + live tick included.
|
|
3433
|
+
# Verified byte-exact vs the successful manual mint 0x1723377a.... Built once
|
|
3434
|
+
# so the same bytes feed both the pre-flight simulation and the broadcast.
|
|
3291
3435
|
feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
|
|
3292
3436
|
payload = build_redstone_payload(feeds)
|
|
3437
|
+
from eth_abi import encode as abi_encode
|
|
3438
|
+
flat_types = ['address', 'address', 'int24', 'int24', 'int24',
|
|
3439
|
+
'uint256', 'uint256', 'uint256', 'uint256',
|
|
3440
|
+
'uint256', 'int24', 'uint256', 'uint256', 'uint256']
|
|
3441
|
+
encoded_params = abi_encode(flat_types, list(params))
|
|
3442
|
+
mint_calldata = AERODROME_SEL_MINT + encoded_params + payload
|
|
3443
|
+
|
|
3444
|
+
# Pre-flight eth_call simulation — catches reverts (insufficient balance,
|
|
3445
|
+
# slippage/PSC, solvency) BEFORE any gas is spent. Gates every broadcast.
|
|
3446
|
+
sim_ok, sim_info = _aero_simulate_mint(w3, acct.address, account.address, mint_calldata)
|
|
3447
|
+
if not sim_ok:
|
|
3448
|
+
print(f" Simulation reverted — aborting before broadcast: {sim_info}")
|
|
3449
|
+
return
|
|
3450
|
+
if sim_info is not None:
|
|
3451
|
+
print(f" Simulation passed — would-be tokenId: {sim_info}")
|
|
3452
|
+
else:
|
|
3453
|
+
print(" Simulation passed.")
|
|
3293
3454
|
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
mint_params_bytes = bytes.fromhex(mint_data[2:])[4:]
|
|
3298
|
-
mint_calldata = AERODROME_SEL_MINT + mint_params_bytes + payload
|
|
3455
|
+
if not execute:
|
|
3456
|
+
print("Preview only. Run with --execute to broadcast.")
|
|
3457
|
+
return
|
|
3299
3458
|
|
|
3300
3459
|
tx = {
|
|
3301
3460
|
"from": acct.address,
|
|
@@ -3328,21 +3487,20 @@ def cmd_aero_remove_liquidity(token_id: int, percentage: float = 100.0,
|
|
|
3328
3487
|
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
3329
3488
|
print(f"Degen Account: {pa}")
|
|
3330
3489
|
|
|
3331
|
-
# Read the position's current liquidity
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3490
|
+
# Read the position's current liquidity from NPM.positions() — correct for
|
|
3491
|
+
# staked positions too (the simplified facet view reports 0 liquidity once the
|
|
3492
|
+
# NFT is held by the gauge).
|
|
3493
|
+
pos = _aero_read_position(w3, token_id)
|
|
3494
|
+
if pos is None:
|
|
3495
|
+
print(f" Cannot read position {token_id}.")
|
|
3336
3496
|
return
|
|
3337
|
-
token0, token1,
|
|
3497
|
+
token0, token1, tick_lower, tick_upper, current_liq = pos
|
|
3338
3498
|
if current_liq == 0:
|
|
3339
3499
|
print(f" Position {token_id} has 0 liquidity (may already be closed).")
|
|
3340
3500
|
return
|
|
3341
3501
|
|
|
3342
3502
|
sym0 = _resolve_token_symbol(w3, token0)
|
|
3343
3503
|
sym1 = _resolve_token_symbol(w3, token1)
|
|
3344
|
-
tick_lower = _int24_from_hi128(tick_data)
|
|
3345
|
-
tick_upper = _int24_from_lo128(tick_data)
|
|
3346
3504
|
|
|
3347
3505
|
remove_liq = int(Decimal(str(current_liq)) * Decimal(str(percentage)) / Decimal(100))
|
|
3348
3506
|
if remove_liq <= 0:
|
|
@@ -3353,16 +3511,26 @@ def cmd_aero_remove_liquidity(token_id: int, percentage: float = 100.0,
|
|
|
3353
3511
|
print(f" Current liquidity: {current_liq}")
|
|
3354
3512
|
print(f" Removing: {remove_liq} ({percentage}%)")
|
|
3355
3513
|
|
|
3356
|
-
if not execute:
|
|
3357
|
-
print("Preview only. Run with --execute to broadcast.")
|
|
3358
|
-
return
|
|
3359
|
-
|
|
3360
3514
|
params = _aero_decrease_params(token_id, remove_liq, 0, 0)
|
|
3361
3515
|
# decreaseLiquidity on the facet (selector ca15558b)
|
|
3362
3516
|
dec_data = account.encode_abi("decreaseAerodromeLiquidity", args=[params])
|
|
3363
3517
|
dec_params_bytes = bytes.fromhex(dec_data[2:])[4:]
|
|
3364
3518
|
dec_calldata = AERODROME_SEL_DECREASE + dec_params_bytes
|
|
3365
3519
|
|
|
3520
|
+
# Pre-flight eth_call simulation — refuse to broadcast on revert. The decrease
|
|
3521
|
+
# path is not RedStone-gated, so a bare eth_call is sufficient.
|
|
3522
|
+
try:
|
|
3523
|
+
w3.eth.call({"from": acct.address, "to": account.address,
|
|
3524
|
+
"data": "0x" + dec_calldata.hex()})
|
|
3525
|
+
except Exception as e:
|
|
3526
|
+
print(f" Simulation reverted — aborting before broadcast: {type(e).__name__}: {str(e)[:200]}")
|
|
3527
|
+
return
|
|
3528
|
+
print(" Simulation passed.")
|
|
3529
|
+
|
|
3530
|
+
if not execute:
|
|
3531
|
+
print("Preview only. Run with --execute to broadcast.")
|
|
3532
|
+
return
|
|
3533
|
+
|
|
3366
3534
|
tx = {
|
|
3367
3535
|
"from": acct.address,
|
|
3368
3536
|
"to": account.address,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: primecli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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.
|
|
50
|
+
**Current version:** 0.8.0 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.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|