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.
Files changed (24) hide show
  1. {primecli-0.7.5 → primecli-0.8.1}/PKG-INFO +2 -2
  2. {primecli-0.7.5 → primecli-0.8.1}/README.md +1 -1
  3. {primecli-0.7.5 → primecli-0.8.1}/primecli/degenprime.py +362 -172
  4. {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/PKG-INFO +2 -2
  5. {primecli-0.7.5 → primecli-0.8.1}/pyproject.toml +1 -1
  6. {primecli-0.7.5 → primecli-0.8.1}/LICENSE +0 -0
  7. {primecli-0.7.5 → primecli-0.8.1}/primecli/__init__.py +0 -0
  8. {primecli-0.7.5 → primecli-0.8.1}/primecli/arbprime.py +0 -0
  9. {primecli-0.7.5 → primecli-0.8.1}/primecli/deltaprime.py +0 -0
  10. {primecli-0.7.5 → primecli-0.8.1}/primecli/health_monitor.py +0 -0
  11. {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/SOURCES.txt +0 -0
  12. {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/dependency_links.txt +0 -0
  13. {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/entry_points.txt +0 -0
  14. {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/requires.txt +0 -0
  15. {primecli-0.7.5 → primecli-0.8.1}/primecli.egg-info/top_level.txt +0 -0
  16. {primecli-0.7.5 → primecli-0.8.1}/setup.cfg +0 -0
  17. {primecli-0.7.5 → primecli-0.8.1}/tests/test_cross_file_identity.py +0 -0
  18. {primecli-0.7.5 → primecli-0.8.1}/tests/test_gas_limit.py +0 -0
  19. {primecli-0.7.5 → primecli-0.8.1}/tests/test_gas_pricing.py +0 -0
  20. {primecli-0.7.5 → primecli-0.8.1}/tests/test_health_meter.py +0 -0
  21. {primecli-0.7.5 → primecli-0.8.1}/tests/test_health_monitor.py +0 -0
  22. {primecli-0.7.5 → primecli-0.8.1}/tests/test_paraswap_validator.py +0 -0
  23. {primecli-0.7.5 → primecli-0.8.1}/tests/test_redstone_encoding.py +0 -0
  24. {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.7.5
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.7.5 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.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.7.5 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
19
+ **Current version:** 0.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 [--percentage 100] [--execute]
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 via the diamond's
80
- getOwnedStakedAerodromeTokenIds + getPositionCompositionSimplified views.
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 (write, onlyOwner, takes MintParams-like tuple — inferred mint/add)
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("27bed82e") # inferred: mint/add
223
+ AERODROME_SEL_MINT = bytes.fromhex("f32f1e56") # mintAndStakeLiquidityAerodrome
216
224
  AERODROME_SEL_INCREASE = bytes.fromhex("2c710777") # inferred: increaseLiquidity
217
- AERODROME_SEL_DECREASE = bytes.fromhex("ca15558b") # inferred: decreaseLiquidity
218
- AERODROME_SEL_BURN = bytes.fromhex("92b5a47e") # inferred: burn
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"}], "name": "getAssetAddress",
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'] / 10**r['decimals']:,.6f}")
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'] / 10**r['decimals']:,.6f}{usd_str}")
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'] / 10**r['decimals']:,.6f}{usd_str}")
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'] / 10**r['decimals']:,.6f}{usd_str}")
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
- print(f" Solvent: {'yes' if solvency['solvent'] else 'NO - liquidatable'}")
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 from the diamond's
3069
- getOwnedStakedAerodromeTokenIds + getPositionCompositionSimplified views."""
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
- # Batch-read position composition via Multicall3 for efficiency.
3090
- # getPositionCompositionSimplified returns (token0, token1, tickData, liquidity).
3091
- # tickData packs tickLower in the upper 128 bits and tickUpper in the lower 128
3092
- # bits as unsigned; the actual int24 values need sign extension.
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
- pos_legs.append((account.address, bytes.fromhex(
3096
- account.encode_abi("getPositionCompositionSimplified", args=[tid])[2:])))
3097
- try:
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
- # 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
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-readable tick range price range
3119
- price_lower = 1.0001 ** tick_lower
3120
- price_upper = 1.0001 ** tick_upper
3121
- print(f" [{tid}] {sym0}/{sym1} ticks=[{tick_lower}, {tick_upper}]"
3122
- f" liq={liq} price_range=[{price_lower:.4f}, {price_upper:.4f}]")
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: build the MintParams tuple for Aerodrome Slipstream (12 fields).
3166
- def _aero_mint_params(pool_cfg: dict, amount0: int, amount1: int,
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
- recipient: str, slippage_pct: float) -> tuple:
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(amount0)) * (Decimal(1) - slippage))
3174
- amount1_min = int(Decimal(str(amount1)) * (Decimal(1) - slippage))
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
- amount0,
3183
- amount1,
3184
- amount0_min,
3185
- amount1_min,
3186
- Web3.to_checksum_address(recipient),
3187
- deadline,
3188
- 0, # sqrtPriceX96: 0 = use current pool price
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 a symmetrical range ±width_pct around
3201
- centre_price. If centre_price is None, uses full-range positions.
3202
- tickSpacing must be valid (1, 5, 10, 30, 100, 200, etc.).
3203
- Ticks are snapped to the tickSpacing grid."""
3204
- if centre_price is None or centre_price <= 0:
3205
- # Full range: MIN_TICK to MAX_TICK (snapped to spacing)
3206
- MIN_TICK = -887272
3207
- MAX_TICK = 887272
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
- # Convert price to tick: tick = floor(log(price) / log(1.0001))
3212
- import math
3213
- centre_tick = int(math.log(centre_price) / math.log(1.0001))
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
- # Get current price from KuCoin for tick range calculation
3263
- price_sym = pool_cfg["symbol0"] + "-USDT"
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
- r = requests.get(f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={price_sym}", timeout=3)
3267
- if r.status_code == 200 and r.json().get("code") == "200000":
3268
- centre_price = float(r.json()["data"]["price"])
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
- pass
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
- pa, slippage_pct)
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 centre_price:
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}: {amount0 or 0} ({amt0} wei) {sym1}: {amount1 or 0} ({amt1} wei)")
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 for solvency check
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
- # Encode the mint call: use the probed selector + ABI-encoded params
3295
- mint_data = account.encode_abi("mintAerodrome", args=[params])
3296
- # encode_abi returns hex string "0x...", extract params bytes after selector
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(token_id: int, percentage: float = 100.0,
3484
+ def cmd_aero_remove_liquidity(token_ids, percentage: float = 100.0,
3313
3485
  execute: bool = False):
3314
- """Remove liquidity from an Aerodrome Slipstream position owned by the
3315
- Degen Account. Decreases liquidity by `percentage` of the current position
3316
- size (default 100% = full close). The position NFT remains; burn it
3317
- separately after all liquidity is removed and fees collected.
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
- # Read the position's current liquidity
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
- pos = account.functions.getPositionCompositionSimplified(token_id).call()
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" Cannot read position {token_id}: {e}")
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": 4000000,
3559
+ "gas": 5000000,
3371
3560
  "chainId": CHAIN_ID,
3372
- "data": "0x" + dec_calldata.hex(),
3561
+ "data": "0x" + close_calldata.hex(),
3373
3562
  }
3374
- receipt = _sign_and_send(w3, acct, tx, "Remove liquidity", timeout=300, fallback_gas=4000000)
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 and percentage >= 100:
3377
- print(f" Position fully withdrawn. Collect fees then burn:")
3378
- print(f" degenprime aero-collect-fees --token-id {token_id} --execute")
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 composition for display
3399
- try:
3400
- pos = account.functions.getPositionCompositionSimplified(token_id).call()
3401
- except Exception as e:
3402
- print(f" Cannot read position {token_id}: {e}")
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, tick_data, liq = pos
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
- token_id = None
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): token_id = int(args[i + 1])
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 token_id is None:
3672
- print("Usage: degenprime aero-remove-liquidity --token-id N [--percentage 100] [--execute]")
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(token_id, percentage, execute)
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.7.5
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.7.5 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.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.5"
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