primecli 0.7.5__tar.gz → 0.8.0__tar.gz

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