primecli 0.7.5__tar.gz → 0.8.1__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.1}/PKG-INFO +2 -2
- {primecli-0.7.5 → primecli-0.8.1}/README.md +1 -1
- {primecli-0.7.5 → primecli-0.8.1}/primecli/degenprime.py +362 -172
- {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.7.5 → primecli-0.8.1}/pyproject.toml +1 -1
- {primecli-0.7.5 → primecli-0.8.1}/LICENSE +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli/__init__.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli/arbprime.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli/deltaprime.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli/health_monitor.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/SOURCES.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/setup.cfg +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_gas_limit.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_gas_pricing.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_health_meter.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_health_monitor.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/tests/test_redstone_encoding.py +0 -0
- {primecli-0.7.5 → primecli-0.8.1}/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.1
|
|
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.1 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.1 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
|
|
|
@@ -27,7 +27,7 @@ Usage:
|
|
|
27
27
|
degenprime cancel-withdrawal --pool usdc --index N [--execute]
|
|
28
28
|
degenprime aerodrome-positions
|
|
29
29
|
degenprime aero-add-liquidity --pool weth-usdc-100 --amount-weth 0.05 --amount-usdc 100 [--slippage 1] [--execute]
|
|
30
|
-
degenprime aero-remove-liquidity --token-id N [--
|
|
30
|
+
degenprime aero-remove-liquidity --token-id N [--token-id M ...] [--execute] (full close only)
|
|
31
31
|
degenprime aero-collect-fees --token-id N [--execute]
|
|
32
32
|
|
|
33
33
|
Configuration (env vars):
|
|
@@ -76,14 +76,22 @@ 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
|
|
84
85
|
AerodromeFacet wrapper functions. The facet selectors were determined via on-chain
|
|
85
86
|
probing (Diamond Loupe) of the smart-loan diamond; function names are inferred from
|
|
86
87
|
their parameter layouts and revert signatures.
|
|
88
|
+
|
|
89
|
+
aero-remove-liquidity fully closes one or more staked positions in a single call via
|
|
90
|
+
batchRemoveStakedLiquidityAerodrome(uint256[]) (selector 0x27bed82e): per tokenId it
|
|
91
|
+
unstakes from the gauge, removes all liquidity, collects fees, and burns the NFT. There
|
|
92
|
+
is no partial/percentage decrease on this path — it always closes 100%. The call is
|
|
93
|
+
solvency-gated, so the calldata carries a RedStone payload (verified byte-exact against
|
|
94
|
+
the manual close 0x0d65...0a50).
|
|
87
95
|
"""
|
|
88
96
|
|
|
89
97
|
import json, os, sys, time, base64
|
|
@@ -207,16 +215,15 @@ AERODROME_NPM = "0x827922686190790b37229fd06084350E74485b72"
|
|
|
207
215
|
# 6f2845cd getOwnedStakedAerodromeTokenIds()
|
|
208
216
|
# b6626971 getPositionCompositionSimplified(uint256) -> (address,address,uint256,uint256)
|
|
209
217
|
# 121350b3 (view, takes uint256 — detailed position info, unknown return)
|
|
210
|
-
# 27bed82e (
|
|
218
|
+
# 27bed82e batchRemoveStakedLiquidityAerodrome(uint256[]) — full close per id
|
|
219
|
+
# (verified byte-exact vs manual close 0x0d65...0a50, 2026-06-14)
|
|
211
220
|
# 2c710777 (write, onlyOwner, takes IncreaseLiquidityParams-like tuple — inferred increase)
|
|
212
|
-
# ca15558b (write, takes DecreaseLiquidityParams tuple 5-field — matches decreaseLiquidity)
|
|
213
221
|
# 92b5a47e (write, takes uint256 tokenId, checks position exists — burn/collect)
|
|
214
222
|
# 46daca2c (write, onlyOwnerOrLiquidator — emergency withdrawal)
|
|
215
|
-
AERODROME_SEL_MINT = bytes.fromhex("
|
|
223
|
+
AERODROME_SEL_MINT = bytes.fromhex("f32f1e56") # mintAndStakeLiquidityAerodrome
|
|
216
224
|
AERODROME_SEL_INCREASE = bytes.fromhex("2c710777") # inferred: increaseLiquidity
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
AERODROME_SEL_COLLECT = bytes.fromhex("92b5a47e") # same as burn (inferred)
|
|
225
|
+
AERODROME_SEL_BURN = bytes.fromhex("27bed82e") # batchRemoveStakedLiquidityAerodrome
|
|
226
|
+
AERODROME_SEL_COLLECT = bytes.fromhex("887e4b7e") # collectAerodromeFees
|
|
220
227
|
|
|
221
228
|
# Whitelisted Aerodrome CL pools exposed as tool keys — the authoritative set of
|
|
222
229
|
# ~31 DegenPrime-supported SlipStream pools. Every entry was verified on-chain
|
|
@@ -800,19 +807,6 @@ PRIME_ACCOUNT_ABI = [
|
|
|
800
807
|
]}],
|
|
801
808
|
"name": "mintAerodrome", "outputs": [],
|
|
802
809
|
"stateMutability": "nonpayable", "type": "function"},
|
|
803
|
-
# decreaseAerodromeLiquidity: wraps NPM.decreaseLiquidity(DecreaseLiquidityParams).
|
|
804
|
-
# DecreaseLiquidityParams = (uint256 tokenId, uint128 liquidity,
|
|
805
|
-
# uint256 amount0Min, uint256 amount1Min, uint256 deadline).
|
|
806
|
-
# Selector: 0xca15558b (probed — accepts 5-field tuple matching decrease layout).
|
|
807
|
-
{"inputs": [{"name": "params", "type": "tuple", "components": [
|
|
808
|
-
{"name": "tokenId", "type": "uint256"},
|
|
809
|
-
{"name": "liquidity", "type": "uint128"},
|
|
810
|
-
{"name": "amount0Min", "type": "uint256"},
|
|
811
|
-
{"name": "amount1Min", "type": "uint256"},
|
|
812
|
-
{"name": "deadline", "type": "uint256"}
|
|
813
|
-
]}],
|
|
814
|
-
"name": "decreaseAerodromeLiquidity", "outputs": [],
|
|
815
|
-
"stateMutability": "nonpayable", "type": "function"},
|
|
816
810
|
# burnAerodromePosition: wraps NPM.burn(uint256). tokenId must have 0 liquidity
|
|
817
811
|
# and all fees collected.
|
|
818
812
|
# Selector: 0x92b5a47e (probed — takes uint256, checks position exists via NPM.positions).
|
|
@@ -824,6 +818,9 @@ PRIME_ACCOUNT_ABI = [
|
|
|
824
818
|
{"inputs": [{"name": "tokenId", "type": "uint256"}],
|
|
825
819
|
"name": "collectAerodromeFees", "outputs": [],
|
|
826
820
|
"stateMutability": "nonpayable", "type": "function"},
|
|
821
|
+
# batchRemoveStakedLiquidityAerodrome(uint256[]) — selector 0x27bed82e
|
|
822
|
+
# (== AERODROME_SEL_BURN, verified). Unstakes + closes the listed positions.
|
|
823
|
+
{"inputs":[{"internalType":"uint256[]","name":"tokenIds","type":"uint256[]"}],"name":"batchRemoveStakedLiquidityAerodrome","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
|
827
824
|
]
|
|
828
825
|
|
|
829
826
|
# TokenManager ABI - minimal subset for symbol/decimals lookups + supported tokens
|
|
@@ -834,7 +831,8 @@ TOKEN_MANAGER_ABI = [
|
|
|
834
831
|
"outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
|
|
835
832
|
{"inputs": [{"name": "_address", "type": "address"}], "name": "tokenAddressToSymbol",
|
|
836
833
|
"outputs": [{"type": "bytes32"}], "stateMutability": "view", "type": "function"},
|
|
837
|
-
{"inputs": [{"name": "_symbol", "type": "bytes32"}
|
|
834
|
+
{"inputs": [{"name": "_symbol", "type": "bytes32"},
|
|
835
|
+
{"name": "_allowInactive", "type": "bool"}], "name": "getAssetAddress",
|
|
838
836
|
"outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
|
|
839
837
|
{"inputs": [], "name": "getSupportedTokensAddresses",
|
|
840
838
|
"outputs": [{"type": "address[]"}], "stateMutability": "view", "type": "function"},
|
|
@@ -947,6 +945,28 @@ def get_prime_account(w3, owner: str) -> str:
|
|
|
947
945
|
def asset_b32(symbol: str) -> bytes:
|
|
948
946
|
return symbol.encode().ljust(32, b"\x00")
|
|
949
947
|
|
|
948
|
+
def fmt_token_amount(raw: int, decimals: int) -> str:
|
|
949
|
+
"""Human token amount that never misleadingly rounds a dust balance UP.
|
|
950
|
+
|
|
951
|
+
Plain f"{x:,.6f}" turns 9.41e-7 into "0.000001" — visually a larger,
|
|
952
|
+
round number than the true value, which is exactly what over-requested a
|
|
953
|
+
dust balance and reverted a mint after burning gas (2026-06-14). For any
|
|
954
|
+
nonzero value that 6-dp formatting would round to zero or up to its own
|
|
955
|
+
last place, append the raw base units so the real size is unambiguous.
|
|
956
|
+
Normal/large balances keep the familiar grouped 6-dp display."""
|
|
957
|
+
if raw == 0:
|
|
958
|
+
return "0"
|
|
959
|
+
amt = Decimal(raw) / (Decimal(10) ** int(decimals))
|
|
960
|
+
rounded6 = amt.quantize(Decimal("0.000001"))
|
|
961
|
+
# Switch to sci-notation + raw wei only for genuinely small balances that 6-dp
|
|
962
|
+
# formatting would misrepresent: rounds to 0 (looks like nothing), or rounds UP
|
|
963
|
+
# below the 1e-4 dust line (e.g. 9.41e-7 -> 0.000001, the over-request trap).
|
|
964
|
+
# Larger values keep the familiar grouped 6-dp display even if the last place
|
|
965
|
+
# rounds — there a round-up isn't misleading and sci-notation would be noise.
|
|
966
|
+
if rounded6 == 0 or (rounded6 > amt and amt < Decimal("0.0001")):
|
|
967
|
+
return f"{amt:.3e} ({raw} wei)"
|
|
968
|
+
return f"{amt:,.6f}"
|
|
969
|
+
|
|
950
970
|
def pool_to_asset_symbol(pool_name: str) -> str:
|
|
951
971
|
"""Pool key -> on-chain bytes32 asset symbol (the contracts use 'ETH', not 'WETH')."""
|
|
952
972
|
return POOLS[pool_name]["symbol"]
|
|
@@ -976,7 +996,7 @@ def _asset_meta(w3, symbol: str):
|
|
|
976
996
|
return _asset_meta_cache[symbol]
|
|
977
997
|
try:
|
|
978
998
|
tm = get_token_manager(w3)
|
|
979
|
-
addr = tm.functions.getAssetAddress(asset_b32(symbol)).call()
|
|
999
|
+
addr = tm.functions.getAssetAddress(asset_b32(symbol), True).call()
|
|
980
1000
|
if int(addr, 16) == 0:
|
|
981
1001
|
_asset_meta_cache[symbol] = (None, 18)
|
|
982
1002
|
return _asset_meta_cache[symbol]
|
|
@@ -2180,7 +2200,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2180
2200
|
if pool_deposits:
|
|
2181
2201
|
print(" Pool Deposits (Diamond Hands):")
|
|
2182
2202
|
for r in pool_deposits:
|
|
2183
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2203
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}")
|
|
2184
2204
|
return
|
|
2185
2205
|
|
|
2186
2206
|
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
@@ -2228,7 +2248,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2228
2248
|
for r in supplied:
|
|
2229
2249
|
usd = solvency["prices"].get(r["symbol"])
|
|
2230
2250
|
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
2231
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2251
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}{usd_str}")
|
|
2232
2252
|
else:
|
|
2233
2253
|
print(" (none)")
|
|
2234
2254
|
|
|
@@ -2237,7 +2257,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2237
2257
|
for r in borrowed:
|
|
2238
2258
|
usd = solvency["prices"].get(r["symbol"])
|
|
2239
2259
|
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
2240
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2260
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}{usd_str}")
|
|
2241
2261
|
else:
|
|
2242
2262
|
print(" (none)")
|
|
2243
2263
|
|
|
@@ -2246,7 +2266,7 @@ def cmd_summary(as_json: bool = False):
|
|
|
2246
2266
|
for r in pool_deposits:
|
|
2247
2267
|
usd = solvency["prices"].get(r["symbol"])
|
|
2248
2268
|
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
2249
|
-
print(f" {r['symbol']:<8} {r['raw']
|
|
2269
|
+
print(f" {r['symbol']:<8} {fmt_token_amount(r['raw'], r['decimals'])}{usd_str}")
|
|
2250
2270
|
|
|
2251
2271
|
if solvency["error"] is None:
|
|
2252
2272
|
# A solvency view can come back None even with no error (e.g. a multicall leg that
|
|
@@ -2275,7 +2295,14 @@ def cmd_summary(as_json: bool = False):
|
|
|
2275
2295
|
print(f" 0%=liquidation 50%=half borrowing power used 100%=no debt")
|
|
2276
2296
|
else:
|
|
2277
2297
|
print(f" Health (0-100%): N/A ({hp['error']})")
|
|
2278
|
-
|
|
2298
|
+
# An account with no debt cannot be liquidated. isSolvent() can come back
|
|
2299
|
+
# None on a no-debt account (empty multicall leg), which used to render a
|
|
2300
|
+
# misleading "NO - liquidatable" despite ratio >1000 and ~$0 debt. Treat
|
|
2301
|
+
# negligible debt (or a >1000 ratio, surfaced as ratio=None) as solvent.
|
|
2302
|
+
negligible_debt = (solvency["debt"] is None or solvency["debt"] < 0.01
|
|
2303
|
+
or solvency["ratio"] is None)
|
|
2304
|
+
is_solvent = bool(solvency["solvent"]) or negligible_debt
|
|
2305
|
+
print(f" Solvent: {'yes' if is_solvent else 'NO - liquidatable'}")
|
|
2279
2306
|
else:
|
|
2280
2307
|
print(f" Health/solvency: RedStone fetch/call failed ({solvency['error']}); showing balances only")
|
|
2281
2308
|
|
|
@@ -3065,8 +3092,9 @@ def cmd_cancel_withdrawal(pool_name: str, index: int, execute: bool = False):
|
|
|
3065
3092
|
|
|
3066
3093
|
def cmd_aerodrome_positions():
|
|
3067
3094
|
"""Read-only: list every Aerodrome Slipstream NFT position the Degen Account
|
|
3068
|
-
owns/stakes, showing token pair, tick range, and liquidity
|
|
3069
|
-
getOwnedStakedAerodromeTokenIds
|
|
3095
|
+
owns/stakes, showing token pair, tick range, and liquidity. Enumerates tokenIds
|
|
3096
|
+
via getOwnedStakedAerodromeTokenIds, then reads each from NPM.positions() (the
|
|
3097
|
+
simplified facet view reports liquidity=0 + garbage ticks for staked NFTs)."""
|
|
3070
3098
|
w3 = get_w3()
|
|
3071
3099
|
acct = get_account()
|
|
3072
3100
|
print(f"Wallet: {acct.address}")
|
|
@@ -3086,42 +3114,51 @@ def cmd_aerodrome_positions():
|
|
|
3086
3114
|
return
|
|
3087
3115
|
print(f" {len(ids)} Aerodrome NFT position(s):")
|
|
3088
3116
|
|
|
3089
|
-
#
|
|
3090
|
-
#
|
|
3091
|
-
#
|
|
3092
|
-
#
|
|
3093
|
-
pos_legs = []
|
|
3117
|
+
# Read each position straight from NPM.positions(). The account's enumerated
|
|
3118
|
+
# tokenIds include STAKED positions whose NFT now belongs to the gauge, and the
|
|
3119
|
+
# facet's getPositionCompositionSimplified reports liquidity=0 + garbage ticks
|
|
3120
|
+
# for those. NPM.positions() returns the real struct for any holder.
|
|
3094
3121
|
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")
|
|
3105
|
-
continue
|
|
3106
|
-
try:
|
|
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")
|
|
3122
|
+
pos = _aero_read_position(w3, tid)
|
|
3123
|
+
if pos is None:
|
|
3124
|
+
print(f" [{tid}] position read failed")
|
|
3111
3125
|
continue
|
|
3112
|
-
|
|
3113
|
-
tick_lower = _int24_from_hi128(tick_data)
|
|
3114
|
-
tick_upper = _int24_from_lo128(tick_data)
|
|
3115
|
-
# Resolve token symbols
|
|
3126
|
+
token0, token1, tick_lower, tick_upper, liq = pos
|
|
3116
3127
|
sym0 = _resolve_token_symbol(w3, token0)
|
|
3117
3128
|
sym1 = _resolve_token_symbol(w3, token1)
|
|
3118
|
-
# Human
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3129
|
+
# Human price = token1 per token0 = 1.0001**tick * 10**(dec0 - dec1).
|
|
3130
|
+
dec0 = _resolve_token_decimals(w3, token0)
|
|
3131
|
+
dec1 = _resolve_token_decimals(w3, token1)
|
|
3132
|
+
if dec0 is not None and dec1 is not None:
|
|
3133
|
+
scale = 10 ** (dec0 - dec1)
|
|
3134
|
+
price_lower = 1.0001 ** tick_lower * scale
|
|
3135
|
+
price_upper = 1.0001 ** tick_upper * scale
|
|
3136
|
+
print(f" [{tid}] {sym0}/{sym1} ticks=[{tick_lower}, {tick_upper}]"
|
|
3137
|
+
f" liq={liq} price_range=[{price_lower:.6g}, {price_upper:.6g}] ({sym1}/{sym0})")
|
|
3138
|
+
else:
|
|
3139
|
+
print(f" [{tid}] {sym0}/{sym1} ticks=[{tick_lower}, {tick_upper}] liq={liq}")
|
|
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
|
|
@@ -3160,62 +3197,159 @@ def _resolve_token_symbol(w3, addr: str) -> str:
|
|
|
3160
3197
|
pass
|
|
3161
3198
|
return addr[:10] + "..."
|
|
3162
3199
|
|
|
3200
|
+
_ERC20_DECIMALS_ABI = json.dumps([{"inputs": [], "name": "decimals",
|
|
3201
|
+
"outputs": [{"type": "uint8"}], "stateMutability": "view", "type": "function"}])
|
|
3202
|
+
|
|
3203
|
+
def _resolve_token_decimals(w3, addr: str):
|
|
3204
|
+
"""Token decimals from the static pool maps, falling back to an on-chain
|
|
3205
|
+
decimals() read. Returns the int decimals or None if it can't be determined."""
|
|
3206
|
+
addr_lower = addr.lower()
|
|
3207
|
+
for cfg in AERODROME_POOLS.values():
|
|
3208
|
+
if cfg["token0"].lower() == addr_lower:
|
|
3209
|
+
return cfg["decimals0"]
|
|
3210
|
+
if cfg["token1"].lower() == addr_lower:
|
|
3211
|
+
return cfg["decimals1"]
|
|
3212
|
+
try:
|
|
3213
|
+
c = w3.eth.contract(address=Web3.to_checksum_address(addr),
|
|
3214
|
+
abi=json.loads(_ERC20_DECIMALS_ABI))
|
|
3215
|
+
return c.functions.decimals().call()
|
|
3216
|
+
except Exception:
|
|
3217
|
+
return None
|
|
3218
|
+
|
|
3163
3219
|
# ─── Aerodrome Write Commands ────────────────────────────────────────────────
|
|
3164
3220
|
|
|
3165
|
-
# Helper:
|
|
3166
|
-
def
|
|
3221
|
+
# Helper: get Aerodrome CL pool address from the factory's getPool (authoritative).
|
|
3222
|
+
def _aero_pool_address(pool_cfg: dict) -> str:
|
|
3223
|
+
"""Resolve the pool address via the factory's getPool()."""
|
|
3224
|
+
try:
|
|
3225
|
+
w3_local = Web3(Web3.HTTPProvider(BASE_RPC))
|
|
3226
|
+
factory = Web3.to_checksum_address("0x5e7BB104d84c7CB9B682AaC2F3d509f5F406809A")
|
|
3227
|
+
import json
|
|
3228
|
+
f_abi = json.loads('[{"inputs":[{"name":"","type":"address"},{"name":"","type":"address"},{"name":"","type":"int24"}],"name":"getPool","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"}]')
|
|
3229
|
+
factory_c = w3_local.eth.contract(address=factory, abi=f_abi)
|
|
3230
|
+
t0 = Web3.to_checksum_address(pool_cfg["token0"])
|
|
3231
|
+
t1 = Web3.to_checksum_address(pool_cfg["token1"])
|
|
3232
|
+
pool = factory_c.functions.getPool(t0, t1, pool_cfg["tickSpacing"]).call()
|
|
3233
|
+
if pool == "0x0000000000000000000000000000000000000000":
|
|
3234
|
+
raise ValueError("Pool does not exist on Aerodrome")
|
|
3235
|
+
return pool
|
|
3236
|
+
except Exception as e:
|
|
3237
|
+
raise RuntimeError(f"Cannot resolve Aerodrome pool: {e}")
|
|
3238
|
+
|
|
3239
|
+
SLOT0_ABI = json.dumps([{"inputs":[],"name":"slot0","outputs":[
|
|
3240
|
+
{"internalType":"uint160","name":"sqrtPriceX96","type":"uint160"},
|
|
3241
|
+
{"internalType":"int24","name":"tick","type":"int24"}],
|
|
3242
|
+
"stateMutability":"view","type":"function"}])
|
|
3243
|
+
|
|
3244
|
+
# Helper: build the 14-field arg tuple for the Aerodrome mint+stake facet fn
|
|
3245
|
+
# (selector 0xf32f1e56). Layout verified byte-exact against the successful
|
|
3246
|
+
# on-chain mint 0x1723377a... (ZORA/USDC, Base, 2026-06-14):
|
|
3247
|
+
# token0, token1, tickLower, tickUpper, tickSpacing,
|
|
3248
|
+
# amount0Desired(wei), amount1Desired(wei), amount0Min(wei), amount1Min(wei),
|
|
3249
|
+
# uint256 const=300, int24 currentTick, 0, 0, 0
|
|
3250
|
+
# Both amounts are NATIVE/wei units; there is NO recipient or unix-deadline
|
|
3251
|
+
# field, tickSpacing IS part of the args, and the last tick field is the live
|
|
3252
|
+
# pool tick (not sqrtPriceX96). The three trailing zero words are
|
|
3253
|
+
# sqrtPriceX96=0 / borrow-if-needed bools = false (all zero either way).
|
|
3254
|
+
def _aero_in_account_balance(account, symbol: str) -> int:
|
|
3255
|
+
"""In-account spendable balance (base units) of `symbol` on the Degen Account.
|
|
3256
|
+
|
|
3257
|
+
The mint+stake facet pulls token0/token1 from the account's own holdings, the
|
|
3258
|
+
same balance getBalance(bytes32) reports (verified equal to ERC20.balanceOf on
|
|
3259
|
+
the account 2026-06-14). Subtract any pending withdrawal-intent lock so we never
|
|
3260
|
+
treat reserved funds as available. Returns 0 if the view reverts."""
|
|
3261
|
+
try:
|
|
3262
|
+
bal = account.functions.getBalance(asset_b32(symbol)).call()
|
|
3263
|
+
except Exception:
|
|
3264
|
+
return 0
|
|
3265
|
+
try:
|
|
3266
|
+
locked = account.functions.getTotalIntentAmount(asset_b32(symbol)).call()
|
|
3267
|
+
except Exception:
|
|
3268
|
+
locked = 0
|
|
3269
|
+
return bal - locked if bal > locked else 0
|
|
3270
|
+
|
|
3271
|
+
def _aero_cap_to_balance(account, pool_cfg: dict, amt0_wei: int, amt1_wei: int) -> tuple:
|
|
3272
|
+
"""Cap each requested amount to what the account actually holds, minus a 1-wei
|
|
3273
|
+
margin so display round-up can never push the request past the real balance.
|
|
3274
|
+
Returns (amt0_capped, amt1_capped, notes) where notes lists human cap messages."""
|
|
3275
|
+
notes = []
|
|
3276
|
+
sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
|
|
3277
|
+
dec0, dec1 = pool_cfg["decimals0"], pool_cfg["decimals1"]
|
|
3278
|
+
for amt, sym, dec, idx in ((amt0_wei, sym0, dec0, 0), (amt1_wei, sym1, dec1, 1)):
|
|
3279
|
+
if amt <= 0:
|
|
3280
|
+
continue
|
|
3281
|
+
avail = _aero_in_account_balance(account, sym)
|
|
3282
|
+
safe = avail - 1 if avail > 0 else 0 # 1-wei margin vs rounding overshoot
|
|
3283
|
+
if amt > safe:
|
|
3284
|
+
capped = safe if safe > 0 else 0
|
|
3285
|
+
notes.append((idx, capped,
|
|
3286
|
+
f"Capped {sym} to {fmt_token_amount(capped, dec)} "
|
|
3287
|
+
f"(on-chain balance {fmt_token_amount(avail, dec)})"))
|
|
3288
|
+
amt0_out, amt1_out = amt0_wei, amt1_wei
|
|
3289
|
+
for idx, capped, _msg in notes:
|
|
3290
|
+
if idx == 0:
|
|
3291
|
+
amt0_out = capped
|
|
3292
|
+
else:
|
|
3293
|
+
amt1_out = capped
|
|
3294
|
+
return amt0_out, amt1_out, [m for _i, _c, m in notes]
|
|
3295
|
+
|
|
3296
|
+
def _aero_simulate_mint(w3, from_addr: str, account_addr: str, calldata: bytes) -> tuple:
|
|
3297
|
+
"""eth_call-simulate the mint+stake before broadcasting. Returns (ok, info):
|
|
3298
|
+
ok=True + would-be tokenId on success, ok=False + revert detail on failure.
|
|
3299
|
+
Read-only — never signs or sends."""
|
|
3300
|
+
try:
|
|
3301
|
+
ret = w3.eth.call({"from": from_addr, "to": account_addr,
|
|
3302
|
+
"data": "0x" + calldata.hex()})
|
|
3303
|
+
except Exception as e:
|
|
3304
|
+
return False, f"{type(e).__name__}: {str(e)[:200]}"
|
|
3305
|
+
token_id = int.from_bytes(bytes(ret)[:32], "big") if len(ret) >= 32 else None
|
|
3306
|
+
return True, token_id
|
|
3307
|
+
|
|
3308
|
+
def _aero_mint_params(pool_cfg: dict, amount0_wei: int, amount1_wei: int,
|
|
3167
3309
|
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."""
|
|
3310
|
+
current_tick: int, slippage_pct: float) -> tuple:
|
|
3172
3311
|
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
|
|
3312
|
+
amount0_min = int(Decimal(str(amount0_wei)) * (Decimal(1) - slippage))
|
|
3313
|
+
amount1_min = int(Decimal(str(amount1_wei)) * (Decimal(1) - slippage))
|
|
3176
3314
|
return (
|
|
3177
3315
|
Web3.to_checksum_address(pool_cfg["token0"]),
|
|
3178
3316
|
Web3.to_checksum_address(pool_cfg["token1"]),
|
|
3179
|
-
pool_cfg["tickSpacing"],
|
|
3180
3317
|
tick_lower,
|
|
3181
3318
|
tick_upper,
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3319
|
+
pool_cfg["tickSpacing"],
|
|
3320
|
+
amount0_wei, # amount0Desired (wei)
|
|
3321
|
+
amount1_wei, # amount1Desired (wei)
|
|
3322
|
+
amount0_min, # amount0Min (wei)
|
|
3323
|
+
amount1_min, # amount1Min (wei)
|
|
3324
|
+
300, # word9 constant (frontend passes 300)
|
|
3325
|
+
int(current_tick), # word10: live pool tick
|
|
3326
|
+
0, 0, 0, # word11-13: zero (sqrtPriceX96=0 / bools false)
|
|
3189
3327
|
)
|
|
3190
3328
|
|
|
3191
|
-
# Helper: build DecreaseLiquidityParams tuple (5 fields).
|
|
3192
|
-
def _aero_decrease_params(token_id: int, liquidity: int,
|
|
3193
|
-
amount0_min: int, amount1_min: int) -> tuple:
|
|
3194
|
-
deadline = int(time.time()) + 1800
|
|
3195
|
-
return (token_id, liquidity, amount0_min, amount1_min, deadline)
|
|
3196
|
-
|
|
3197
3329
|
# Helper: compute tick range around a desired centre price.
|
|
3198
3330
|
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
|
-
|
|
3331
|
+
width_pct: float = 2.0, pool_tick: int = None) -> tuple:
|
|
3332
|
+
"""Return (tickLower, tickUpper) for +/-width_pct around centre.
|
|
3333
|
+
Priority: pool_tick > centre_price > full range."""
|
|
3334
|
+
import math
|
|
3335
|
+
MIN_TICK, MAX_TICK = -887272, 887272
|
|
3336
|
+
|
|
3337
|
+
if pool_tick is not None:
|
|
3338
|
+
tick_delta = int(abs(math.log(1.0 + width_pct / 100.0) / math.log(1.0001))) + 1
|
|
3339
|
+
raw_lower = pool_tick - tick_delta
|
|
3340
|
+
raw_upper = pool_tick + tick_delta
|
|
3341
|
+
elif centre_price is not None and centre_price > 0:
|
|
3342
|
+
lower_price = centre_price * max(1e-12, 1.0 - width_pct / 100.0)
|
|
3343
|
+
upper_price = centre_price * (1.0 + width_pct / 100.0)
|
|
3344
|
+
raw_lower = math.log(lower_price) / math.log(1.0001)
|
|
3345
|
+
raw_upper = math.log(upper_price) / math.log(1.0001)
|
|
3346
|
+
else:
|
|
3208
3347
|
t_lower = (MIN_TICK // tick_spacing) * tick_spacing
|
|
3209
3348
|
t_upper = (MAX_TICK // tick_spacing) * tick_spacing
|
|
3210
3349
|
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
|
|
3350
|
+
|
|
3351
|
+
tick_lower = math.floor(raw_lower / tick_spacing) * tick_spacing
|
|
3352
|
+
tick_upper = math.ceil(raw_upper / tick_spacing) * tick_spacing
|
|
3219
3353
|
tick_lower = max(MIN_TICK, min(MAX_TICK, tick_lower))
|
|
3220
3354
|
tick_upper = max(MIN_TICK, min(MAX_TICK, tick_upper))
|
|
3221
3355
|
if tick_lower >= tick_upper:
|
|
@@ -3259,43 +3393,81 @@ def cmd_aero_add_liquidity(pool_key: str, amount0: float = None,
|
|
|
3259
3393
|
print("At least one of --amount-<token0> / --amount-<token1> must be > 0")
|
|
3260
3394
|
return
|
|
3261
3395
|
|
|
3262
|
-
#
|
|
3263
|
-
|
|
3396
|
+
# Auto-cap each side to the account's real in-account balance. The summary
|
|
3397
|
+
# display can round a dust balance UP, so a request matching the shown value
|
|
3398
|
+
# could exceed the true balance and revert AFTER burning gas (2026-06-14).
|
|
3399
|
+
amt0, amt1, cap_notes = _aero_cap_to_balance(account, pool_cfg, amt0, amt1)
|
|
3400
|
+
for note in cap_notes:
|
|
3401
|
+
print(f" {note}")
|
|
3402
|
+
if amt0 == 0 and amt1 == 0:
|
|
3403
|
+
print(" Nothing to deposit after capping to on-chain balance.")
|
|
3404
|
+
return
|
|
3405
|
+
|
|
3406
|
+
# Get pool's on-chain tick from slot0 for accurate range computation
|
|
3407
|
+
pool_tick = None
|
|
3264
3408
|
centre_price = None
|
|
3265
3409
|
try:
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3410
|
+
pool_addr = _aero_pool_address(pool_cfg)
|
|
3411
|
+
pool_abi = json.loads(SLOT0_ABI)
|
|
3412
|
+
pool_c = w3.eth.contract(address=pool_addr, abi=pool_abi)
|
|
3413
|
+
slot0 = pool_c.functions.slot0().call()
|
|
3414
|
+
pool_tick = slot0[1]
|
|
3415
|
+
print(f" Pool tick (on-chain): {pool_tick}")
|
|
3269
3416
|
except Exception:
|
|
3270
|
-
|
|
3417
|
+
# Fallback to KuCoin price
|
|
3418
|
+
price_sym = pool_cfg["symbol0"] + "-USDT"
|
|
3419
|
+
try:
|
|
3420
|
+
r = requests.get(f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={price_sym}", timeout=3)
|
|
3421
|
+
if r.status_code == 200 and r.json().get("code") == "200000":
|
|
3422
|
+
centre_price = float(r.json()["data"]["price"])
|
|
3423
|
+
except Exception:
|
|
3424
|
+
pass
|
|
3271
3425
|
|
|
3272
|
-
tick_lower, tick_upper = _aero_tick_range(pool_cfg["tickSpacing"], centre_price, width_pct)
|
|
3426
|
+
tick_lower, tick_upper = _aero_tick_range(pool_cfg["tickSpacing"], centre_price, width_pct, pool_tick)
|
|
3273
3427
|
params = _aero_mint_params(pool_cfg, amt0, amt1, tick_lower, tick_upper,
|
|
3274
|
-
|
|
3428
|
+
pool_tick if pool_tick is not None else (tick_lower + tick_upper) // 2,
|
|
3429
|
+
slippage_pct)
|
|
3275
3430
|
|
|
3276
3431
|
sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
|
|
3277
3432
|
print(f"Pool: {sym0}/{sym1} (tickSpacing={pool_cfg['tickSpacing']})")
|
|
3278
|
-
if
|
|
3433
|
+
if pool_tick is not None:
|
|
3434
|
+
print(f" Tick range: [{tick_lower}, {tick_upper}] (width: +/-{width_pct}%)")
|
|
3435
|
+
elif centre_price:
|
|
3279
3436
|
print(f" Current {sym0} price: ${centre_price:,.2f}")
|
|
3280
3437
|
print(f" Tick range: [{tick_lower}, {tick_upper}] → price [{1.0001**tick_lower:.4f}, {1.0001**tick_upper:.4f}]")
|
|
3281
3438
|
print(f" Width: ±{width_pct}%")
|
|
3282
3439
|
else:
|
|
3283
3440
|
print(f" Full-range position (no price data available)")
|
|
3284
|
-
print(f" {sym0}: {
|
|
3285
|
-
|
|
3286
|
-
if not execute:
|
|
3287
|
-
print("Preview only. Run with --execute to broadcast.")
|
|
3288
|
-
return
|
|
3441
|
+
print(f" {sym0}: {fmt_token_amount(amt0, pool_cfg['decimals0'])} "
|
|
3442
|
+
f"{sym1}: {fmt_token_amount(amt1, pool_cfg['decimals1'])}")
|
|
3289
3443
|
|
|
3290
|
-
# Build RedStone payload
|
|
3444
|
+
# Build RedStone payload + final mint+stake calldata (selector 0xf32f1e56).
|
|
3445
|
+
# 14 flat args; amounts in native wei; tickSpacing + live tick included.
|
|
3446
|
+
# Verified byte-exact vs the successful manual mint 0x1723377a.... Built once
|
|
3447
|
+
# so the same bytes feed both the pre-flight simulation and the broadcast.
|
|
3291
3448
|
feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
|
|
3292
3449
|
payload = build_redstone_payload(feeds)
|
|
3450
|
+
from eth_abi import encode as abi_encode
|
|
3451
|
+
flat_types = ['address', 'address', 'int24', 'int24', 'int24',
|
|
3452
|
+
'uint256', 'uint256', 'uint256', 'uint256',
|
|
3453
|
+
'uint256', 'int24', 'uint256', 'uint256', 'uint256']
|
|
3454
|
+
encoded_params = abi_encode(flat_types, list(params))
|
|
3455
|
+
mint_calldata = AERODROME_SEL_MINT + encoded_params + payload
|
|
3456
|
+
|
|
3457
|
+
# Pre-flight eth_call simulation — catches reverts (insufficient balance,
|
|
3458
|
+
# slippage/PSC, solvency) BEFORE any gas is spent. Gates every broadcast.
|
|
3459
|
+
sim_ok, sim_info = _aero_simulate_mint(w3, acct.address, account.address, mint_calldata)
|
|
3460
|
+
if not sim_ok:
|
|
3461
|
+
print(f" Simulation reverted — aborting before broadcast: {sim_info}")
|
|
3462
|
+
return
|
|
3463
|
+
if sim_info is not None:
|
|
3464
|
+
print(f" Simulation passed — would-be tokenId: {sim_info}")
|
|
3465
|
+
else:
|
|
3466
|
+
print(" Simulation passed.")
|
|
3293
3467
|
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
mint_params_bytes = bytes.fromhex(mint_data[2:])[4:]
|
|
3298
|
-
mint_calldata = AERODROME_SEL_MINT + mint_params_bytes + payload
|
|
3468
|
+
if not execute:
|
|
3469
|
+
print("Preview only. Run with --execute to broadcast.")
|
|
3470
|
+
return
|
|
3299
3471
|
|
|
3300
3472
|
tx = {
|
|
3301
3473
|
"from": acct.address,
|
|
@@ -3309,15 +3481,31 @@ def cmd_aero_add_liquidity(pool_key: str, amount0: float = None,
|
|
|
3309
3481
|
ok = receipt["status"] == 1
|
|
3310
3482
|
|
|
3311
3483
|
|
|
3312
|
-
def cmd_aero_remove_liquidity(
|
|
3484
|
+
def cmd_aero_remove_liquidity(token_ids, percentage: float = 100.0,
|
|
3313
3485
|
execute: bool = False):
|
|
3314
|
-
"""
|
|
3315
|
-
Degen Account
|
|
3316
|
-
|
|
3317
|
-
|
|
3486
|
+
"""Fully close one or more staked Aerodrome Slipstream positions owned by the
|
|
3487
|
+
Degen Account, via batchRemoveStakedLiquidityAerodrome(uint256[]) on the
|
|
3488
|
+
AerodromeFacet (selector 0x27bed82e). This single call does the FULL unwind per
|
|
3489
|
+
tokenId: unstake from the gauge + remove all liquidity + collect fees + burn the
|
|
3490
|
+
NFT (the manual reference close 0x0d65... emitted 41 logs doing exactly this).
|
|
3491
|
+
|
|
3492
|
+
There is NO partial/percentage decrease on this path — it always closes 100%.
|
|
3493
|
+
The call is remainsSolvent-gated, so the calldata carries a RedStone signed-price
|
|
3494
|
+
payload (same construction as the mint+stake path)."""
|
|
3495
|
+
if percentage < 100:
|
|
3496
|
+
print(f" Partial removal ({percentage}%) is not supported on this path — "
|
|
3497
|
+
f"batchRemoveStakedLiquidityAerodrome fully closes each position "
|
|
3498
|
+
f"(unstake + remove + collect + burn). Re-run without --percentage "
|
|
3499
|
+
f"(or with --percentage 100) to fully close.")
|
|
3500
|
+
return
|
|
3501
|
+
|
|
3502
|
+
if isinstance(token_ids, int):
|
|
3503
|
+
token_ids = [token_ids]
|
|
3504
|
+
token_ids = [int(t) for t in token_ids]
|
|
3505
|
+
if not token_ids:
|
|
3506
|
+
print(" No tokenIds supplied.")
|
|
3507
|
+
return
|
|
3318
3508
|
|
|
3319
|
-
The facet wraps NPM.decreaseLiquidity. No RedStone payload needed
|
|
3320
|
-
(the decrease path is NOT remainsSolvent — same as TJ lb-remove)."""
|
|
3321
3509
|
w3 = get_w3()
|
|
3322
3510
|
acct = get_account()
|
|
3323
3511
|
print(f"Wallet: {acct.address}")
|
|
@@ -3328,54 +3516,55 @@ def cmd_aero_remove_liquidity(token_id: int, percentage: float = 100.0,
|
|
|
3328
3516
|
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
3329
3517
|
print(f"Degen Account: {pa}")
|
|
3330
3518
|
|
|
3331
|
-
#
|
|
3519
|
+
# Show what each position holds before closing (NPM.positions() is correct for
|
|
3520
|
+
# staked NFTs, which the simplified facet view reports as 0 liquidity).
|
|
3521
|
+
for tid in token_ids:
|
|
3522
|
+
pos = _aero_read_position(w3, tid)
|
|
3523
|
+
if pos is None:
|
|
3524
|
+
print(f" Position {tid}: cannot read (may not exist).")
|
|
3525
|
+
continue
|
|
3526
|
+
token0, token1, tick_lower, tick_upper, current_liq = pos
|
|
3527
|
+
sym0 = _resolve_token_symbol(w3, token0)
|
|
3528
|
+
sym1 = _resolve_token_symbol(w3, token1)
|
|
3529
|
+
print(f"Position {tid}: {sym0}/{sym1} ticks=[{tick_lower},{tick_upper}] "
|
|
3530
|
+
f"liquidity={current_liq}")
|
|
3531
|
+
|
|
3532
|
+
# Encode batchRemoveStakedLiquidityAerodrome(uint256[] tokenIds) and append the
|
|
3533
|
+
# RedStone payload raw. Byte-for-byte layout (selector + uint256[] head + payload)
|
|
3534
|
+
# verified against the manual close 0x0d65...0a50.
|
|
3535
|
+
from eth_abi import encode as abi_encode
|
|
3536
|
+
encoded_ids = abi_encode(['uint256[]'], [token_ids])
|
|
3537
|
+
feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
|
|
3538
|
+
payload = build_redstone_payload(feeds)
|
|
3539
|
+
close_calldata = AERODROME_SEL_BURN + encoded_ids + payload
|
|
3540
|
+
|
|
3541
|
+
# Pre-flight eth_call simulation — refuse to broadcast on revert. The close path
|
|
3542
|
+
# IS RedStone-gated, so the simulated calldata already carries the payload.
|
|
3332
3543
|
try:
|
|
3333
|
-
|
|
3544
|
+
w3.eth.call({"from": acct.address, "to": account.address,
|
|
3545
|
+
"data": "0x" + close_calldata.hex()})
|
|
3334
3546
|
except Exception as e:
|
|
3335
|
-
print(f"
|
|
3336
|
-
return
|
|
3337
|
-
token0, token1, tick_data, current_liq = pos
|
|
3338
|
-
if current_liq == 0:
|
|
3339
|
-
print(f" Position {token_id} has 0 liquidity (may already be closed).")
|
|
3547
|
+
print(f" Simulation reverted — aborting before broadcast: {type(e).__name__}: {str(e)[:200]}")
|
|
3340
3548
|
return
|
|
3341
|
-
|
|
3342
|
-
sym0 = _resolve_token_symbol(w3, token0)
|
|
3343
|
-
sym1 = _resolve_token_symbol(w3, token1)
|
|
3344
|
-
tick_lower = _int24_from_hi128(tick_data)
|
|
3345
|
-
tick_upper = _int24_from_lo128(tick_data)
|
|
3346
|
-
|
|
3347
|
-
remove_liq = int(Decimal(str(current_liq)) * Decimal(str(percentage)) / Decimal(100))
|
|
3348
|
-
if remove_liq <= 0:
|
|
3349
|
-
print(f" Removal percentage {percentage}% yields 0 liquidity.")
|
|
3350
|
-
return
|
|
3351
|
-
|
|
3352
|
-
print(f"Position {token_id}: {sym0}/{sym1} ticks=[{tick_lower},{tick_upper}]")
|
|
3353
|
-
print(f" Current liquidity: {current_liq}")
|
|
3354
|
-
print(f" Removing: {remove_liq} ({percentage}%)")
|
|
3549
|
+
print(" Simulation passed.")
|
|
3355
3550
|
|
|
3356
3551
|
if not execute:
|
|
3357
3552
|
print("Preview only. Run with --execute to broadcast.")
|
|
3358
3553
|
return
|
|
3359
3554
|
|
|
3360
|
-
params = _aero_decrease_params(token_id, remove_liq, 0, 0)
|
|
3361
|
-
# decreaseLiquidity on the facet (selector ca15558b)
|
|
3362
|
-
dec_data = account.encode_abi("decreaseAerodromeLiquidity", args=[params])
|
|
3363
|
-
dec_params_bytes = bytes.fromhex(dec_data[2:])[4:]
|
|
3364
|
-
dec_calldata = AERODROME_SEL_DECREASE + dec_params_bytes
|
|
3365
|
-
|
|
3366
3555
|
tx = {
|
|
3367
3556
|
"from": acct.address,
|
|
3368
3557
|
"to": account.address,
|
|
3369
3558
|
"nonce": w3.eth.get_transaction_count(acct.address),
|
|
3370
|
-
"gas":
|
|
3559
|
+
"gas": 5000000,
|
|
3371
3560
|
"chainId": CHAIN_ID,
|
|
3372
|
-
"data": "0x" +
|
|
3561
|
+
"data": "0x" + close_calldata.hex(),
|
|
3373
3562
|
}
|
|
3374
|
-
receipt = _sign_and_send(w3, acct, tx, "
|
|
3563
|
+
receipt = _sign_and_send(w3, acct, tx, "Close Aerodrome position(s)", timeout=300, fallback_gas=5000000)
|
|
3375
3564
|
ok = receipt["status"] == 1
|
|
3376
|
-
if ok
|
|
3377
|
-
|
|
3378
|
-
print(f"
|
|
3565
|
+
if ok:
|
|
3566
|
+
ids_str = ", ".join(str(t) for t in token_ids)
|
|
3567
|
+
print(f" Fully closed (unstaked + removed + collected + burned): {ids_str}")
|
|
3379
3568
|
|
|
3380
3569
|
|
|
3381
3570
|
def cmd_aero_collect_fees(token_id: int, execute: bool = False):
|
|
@@ -3395,13 +3584,13 @@ def cmd_aero_collect_fees(token_id: int, execute: bool = False):
|
|
|
3395
3584
|
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
3396
3585
|
print(f"Degen Account: {pa}")
|
|
3397
3586
|
|
|
3398
|
-
# Read position
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
print(f" Cannot read position {token_id}
|
|
3587
|
+
# Read position for display via NPM.positions() — correct for staked NFTs (the
|
|
3588
|
+
# simplified facet view reports liquidity=0 once the gauge owns the NFT).
|
|
3589
|
+
pos = _aero_read_position(w3, token_id)
|
|
3590
|
+
if pos is None:
|
|
3591
|
+
print(f" Cannot read position {token_id}.")
|
|
3403
3592
|
return
|
|
3404
|
-
token0, token1,
|
|
3593
|
+
token0, token1, tick_lower, tick_upper, liq = pos
|
|
3405
3594
|
sym0 = _resolve_token_symbol(w3, token0)
|
|
3406
3595
|
sym1 = _resolve_token_symbol(w3, token1)
|
|
3407
3596
|
print(f"Position {token_id}: {sym0}/{sym1} liquidity={liq}")
|
|
@@ -3662,16 +3851,17 @@ def _dispatch():
|
|
|
3662
3851
|
return
|
|
3663
3852
|
cmd_aero_add_liquidity(pool_key, amt0, amt1, slippage, execute, width)
|
|
3664
3853
|
elif cmd == "aero-remove-liquidity":
|
|
3665
|
-
|
|
3854
|
+
token_ids = []
|
|
3666
3855
|
percentage = 100.0
|
|
3667
3856
|
execute = "--execute" in args
|
|
3668
3857
|
for i, a in enumerate(args):
|
|
3669
|
-
if a == "--token-id" and i + 1 < len(args):
|
|
3858
|
+
if a == "--token-id" and i + 1 < len(args): token_ids.append(int(args[i + 1]))
|
|
3670
3859
|
if a == "--percentage" and i + 1 < len(args): percentage = float(args[i + 1])
|
|
3671
|
-
if
|
|
3672
|
-
print("Usage: degenprime aero-remove-liquidity --token-id N [--
|
|
3860
|
+
if not token_ids:
|
|
3861
|
+
print("Usage: degenprime aero-remove-liquidity --token-id N [--token-id M ...] [--execute]")
|
|
3862
|
+
print(" Fully closes (unstake + remove + collect + burn) each staked position. Full close only.")
|
|
3673
3863
|
return
|
|
3674
|
-
cmd_aero_remove_liquidity(
|
|
3864
|
+
cmd_aero_remove_liquidity(token_ids, percentage, execute)
|
|
3675
3865
|
elif cmd == "aero-collect-fees":
|
|
3676
3866
|
token_id = None
|
|
3677
3867
|
execute = "--execute" in args
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: primecli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
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.1 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.1"
|
|
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
|