primecli 0.7.4__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.4 → primecli-0.8.0}/PKG-INFO +2 -2
  2. {primecli-0.7.4 → primecli-0.8.0}/README.md +1 -1
  3. {primecli-0.7.4 → primecli-0.8.0}/primecli/arbprime.py +26 -1
  4. {primecli-0.7.4 → primecli-0.8.0}/primecli/degenprime.py +275 -107
  5. {primecli-0.7.4 → primecli-0.8.0}/primecli/deltaprime.py +26 -1
  6. {primecli-0.7.4 → primecli-0.8.0}/primecli/health_monitor.py +234 -101
  7. {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/PKG-INFO +2 -2
  8. {primecli-0.7.4 → primecli-0.8.0}/pyproject.toml +1 -1
  9. {primecli-0.7.4 → primecli-0.8.0}/LICENSE +0 -0
  10. {primecli-0.7.4 → primecli-0.8.0}/primecli/__init__.py +0 -0
  11. {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/SOURCES.txt +0 -0
  12. {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/dependency_links.txt +0 -0
  13. {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/entry_points.txt +0 -0
  14. {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/requires.txt +0 -0
  15. {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/top_level.txt +0 -0
  16. {primecli-0.7.4 → primecli-0.8.0}/setup.cfg +0 -0
  17. {primecli-0.7.4 → primecli-0.8.0}/tests/test_cross_file_identity.py +0 -0
  18. {primecli-0.7.4 → primecli-0.8.0}/tests/test_gas_limit.py +0 -0
  19. {primecli-0.7.4 → primecli-0.8.0}/tests/test_gas_pricing.py +0 -0
  20. {primecli-0.7.4 → primecli-0.8.0}/tests/test_health_meter.py +0 -0
  21. {primecli-0.7.4 → primecli-0.8.0}/tests/test_health_monitor.py +0 -0
  22. {primecli-0.7.4 → primecli-0.8.0}/tests/test_paraswap_validator.py +0 -0
  23. {primecli-0.7.4 → primecli-0.8.0}/tests/test_redstone_encoding.py +0 -0
  24. {primecli-0.7.4 → 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.4
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.4 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.4 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
 
@@ -262,6 +262,7 @@ AGENTS = {
262
262
  }
263
263
  _SELECTED_AGENT = None # set by the --as CLI flag in main()
264
264
  _CLI_KEY = None # set by the --key CLI flag in main()
265
+ _OWNER_ADDRESS = None # set by --owner for keyless read-only commands (main())
265
266
  # Core protocol addresses — the LIVE Arbitrum deployment (DeploymentConstants.sol),
266
267
  # on-chain verified 2026-06-03. The stale *TUP.json deployment (factory 0x97f4C81…)
267
268
  # has only ETH+USDC pools — NOT used here.
@@ -847,6 +848,14 @@ def resolve_private_key():
847
848
  )
848
849
 
849
850
  def get_account() -> Account:
851
+ # --owner provides a keyless read-only account (address only, cannot sign) for
852
+ # monitoring/sim reads that need the wallet owner (e.g. to locate a Prime Account)
853
+ # but never broadcast. Write paths are blocked in main() when --owner is set.
854
+ if _OWNER_ADDRESS:
855
+ class _ReadOnlyAccount:
856
+ def __init__(self, address):
857
+ self.address = Web3.to_checksum_address(address)
858
+ return _ReadOnlyAccount(_OWNER_ADDRESS)
850
859
  return Account.from_key(resolve_private_key())
851
860
 
852
861
  def to_wei_units(amount, decimals):
@@ -5587,7 +5596,7 @@ def main():
5587
5596
  check_version()
5588
5597
  args = sys.argv[1:] if len(sys.argv) > 1 else []
5589
5598
  # Global wallet selector: --as <agent>, stripped before command dispatch.
5590
- global _SELECTED_AGENT, _CLI_KEY
5599
+ global _SELECTED_AGENT, _CLI_KEY, _OWNER_ADDRESS
5591
5600
  if "--as" in args:
5592
5601
  i = args.index("--as")
5593
5602
  if i + 1 >= len(args):
@@ -5603,11 +5612,27 @@ def main():
5603
5612
  return
5604
5613
  _CLI_KEY = args[i + 1]
5605
5614
  del args[i:i + 2]
5615
+ # Public owner-address selector for read-only commands. Lets monitoring/sim jobs
5616
+ # inspect a wallet's Prime Account / positions without resolving or loading a key.
5617
+ if "--owner" in args:
5618
+ i = args.index("--owner")
5619
+ if i + 1 >= len(args):
5620
+ print("--owner requires an EVM address. Example: --owner 0xabc...")
5621
+ return
5622
+ try:
5623
+ _OWNER_ADDRESS = Web3.to_checksum_address(args[i + 1])
5624
+ except Exception:
5625
+ print(f"Invalid --owner address: {args[i + 1]}")
5626
+ return
5627
+ del args[i:i + 2]
5606
5628
  if not args or args[0] in ("-h", "--help"):
5607
5629
  print(__doc__)
5608
5630
  return
5609
5631
 
5610
5632
  cmd = args[0]
5633
+ if _OWNER_ADDRESS and cmd not in {"defi", "lb-positions"}:
5634
+ print("--owner is only supported for read-only commands: defi, lb-positions")
5635
+ return
5611
5636
  if cmd == "pool-info":
5612
5637
  # First positional after `pool-info` is the pool name; --json is an opt-in flag
5613
5638
  # that switches output from human tables to a compact JSON shape (one object for
@@ -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,
@@ -269,6 +269,7 @@ AGENTS = {
269
269
  }
270
270
  _SELECTED_AGENT = None # set by the --as CLI flag in main()
271
271
  _CLI_KEY = None # set by the --key CLI flag in main()
272
+ _OWNER_ADDRESS = None # set by --owner for keyless read-only commands (main())
272
273
  SNOWTRACE = "https://api.snowtrace.io/api"
273
274
  FACTORY_PROXY = "0x3Ea9D480295A73fd2aF95b4D96c2afF88b21B03D"
274
275
  # On-chain registry of active pools. getPoolAddress(bytes32 asset) is the source
@@ -842,6 +843,14 @@ def resolve_private_key():
842
843
  )
843
844
 
844
845
  def get_account() -> Account:
846
+ # --owner provides a keyless read-only account (address only, cannot sign) for
847
+ # monitoring/sim reads that need the wallet owner (e.g. to locate a Prime Account)
848
+ # but never broadcast. Write paths are blocked in main() when --owner is set.
849
+ if _OWNER_ADDRESS:
850
+ class _ReadOnlyAccount:
851
+ def __init__(self, address):
852
+ self.address = Web3.to_checksum_address(address)
853
+ return _ReadOnlyAccount(_OWNER_ADDRESS)
845
854
  return Account.from_key(resolve_private_key())
846
855
 
847
856
  def to_wei_units(amount, decimals):
@@ -5358,7 +5367,7 @@ def main():
5358
5367
  check_version()
5359
5368
  args = sys.argv[1:] if len(sys.argv) > 1 else []
5360
5369
  # Global wallet selector: --as <agent>, stripped before command dispatch.
5361
- global _SELECTED_AGENT, _CLI_KEY
5370
+ global _SELECTED_AGENT, _CLI_KEY, _OWNER_ADDRESS
5362
5371
  if "--as" in args:
5363
5372
  i = args.index("--as")
5364
5373
  if i + 1 >= len(args):
@@ -5374,11 +5383,27 @@ def main():
5374
5383
  return
5375
5384
  _CLI_KEY = args[i + 1]
5376
5385
  del args[i:i + 2]
5386
+ # Public owner-address selector for read-only commands. Lets monitoring/sim jobs
5387
+ # inspect a wallet's Prime Account / positions without resolving or loading a key.
5388
+ if "--owner" in args:
5389
+ i = args.index("--owner")
5390
+ if i + 1 >= len(args):
5391
+ print("--owner requires an EVM address. Example: --owner 0xabc...")
5392
+ return
5393
+ try:
5394
+ _OWNER_ADDRESS = Web3.to_checksum_address(args[i + 1])
5395
+ except Exception:
5396
+ print(f"Invalid --owner address: {args[i + 1]}")
5397
+ return
5398
+ del args[i:i + 2]
5377
5399
  if not args or args[0] in ("-h", "--help"):
5378
5400
  print(__doc__)
5379
5401
  return
5380
5402
 
5381
5403
  cmd = args[0]
5404
+ if _OWNER_ADDRESS and cmd not in {"defi", "lb-positions"}:
5405
+ print("--owner is only supported for read-only commands: defi, lb-positions")
5406
+ return
5382
5407
  if cmd == "pool-info":
5383
5408
  # First positional after `pool-info` is the pool name; --json is an opt-in flag
5384
5409
  # that switches output from human tables to a compact JSON shape (one object for
@@ -214,6 +214,22 @@ def append_history(state_dir: str, entry: dict):
214
214
  path.write_text("\n".join(lines[-1000:]) + "\n")
215
215
 
216
216
 
217
+ NOTIFY_SCRIPT = os.path.expanduser("/root/.openclaw/workspace/scripts/notify.sh")
218
+
219
+
220
+ def _notify(text: str):
221
+ """Send a Telegram notification via the notify.sh script."""
222
+ if not os.path.exists(NOTIFY_SCRIPT):
223
+ return
224
+ try:
225
+ subprocess.run(
226
+ ["bash", NOTIFY_SCRIPT, text],
227
+ capture_output=True, timeout=30,
228
+ )
229
+ except Exception:
230
+ pass
231
+
232
+
217
233
  def load_baseline_equity(state_dir: str) -> float | None:
218
234
  """Load baseline equity for stop-loss tracking."""
219
235
  path = Path(state_dir) / "baseline-equity"
@@ -325,7 +341,46 @@ def run_tick(
325
341
  # 3. Compute health
326
342
  health = compute_health(defi_data, max_mult)
327
343
  health["tier"] = tier
344
+
345
+ # 4. Load strategy (do this early so rebalance mode is known before equity checks)
346
+ strategy = load_strategy(strategy_path)
347
+ mode = strategy.get("mode", "observer")
348
+ health["mode"] = mode
349
+ result.update(health)
350
+ result["mode"] = mode
351
+
352
+ # 5. Check for unfunded or unpriced accounts
328
353
  if health.get("error") == "equity near zero":
354
+ # Check if there are actual token balances without USD prices (RedStone off?)
355
+ has_balances = False
356
+ groups = defi_data.get("groups", [])
357
+ if groups:
358
+ for g in groups:
359
+ for s in g.get("supplied", []):
360
+ bal = s.get("balance", 0)
361
+ try:
362
+ if float(bal) > 0:
363
+ has_balances = True
364
+ break
365
+ except (ValueError, TypeError):
366
+ pass
367
+ if has_balances:
368
+ break
369
+ else:
370
+ for s in defi_data.get("supplied", []):
371
+ bal = s.get("balance", 0)
372
+ try:
373
+ if float(bal) > 0:
374
+ has_balances = True
375
+ break
376
+ except (ValueError, TypeError):
377
+ pass
378
+
379
+ if has_balances:
380
+ # Positions exist but USD prices unavailable — skip this tick
381
+ result["action"] = "skip (unpriced positions)"
382
+ return result
383
+
329
384
  # Only escalate if there's actual debt — an empty unfunded wallet is not an emergency
330
385
  if health.get("debt_usd", 0) and health["debt_usd"] > 0.5:
331
386
  write_escalation(state_dir, "equity-near-zero", {
@@ -338,17 +393,9 @@ def run_tick(
338
393
  result["mode"] = "escalated"
339
394
  else:
340
395
  result["action"] = "none (unfunded account)"
341
- result.update(health)
342
396
  return result
343
397
 
344
- # 4. Load strategy (position/market/side are optional hints now — auto-detected from defi_data)
345
- strategy = load_strategy(strategy_path)
346
- mode = strategy.get("mode", "observer")
347
- health["mode"] = mode
348
- result.update(health)
349
- result["mode"] = mode
350
-
351
- # 5. Health swing detection (always)
398
+ # 6. Health swing detection (always)
352
399
  last_pct = load_last_health(state_dir)
353
400
  if last_pct is not None and health["health_pct"] is not None:
354
401
  diff = abs(health["health_pct"] - last_pct)
@@ -363,7 +410,7 @@ def run_tick(
363
410
  result["escalation"] = "health_swing"
364
411
  save_last_health(state_dir, health["health_pct"] or 0.0)
365
412
 
366
- # 6. Append to history
413
+ # 7. Append to history
367
414
  entry = {
368
415
  "ts": now_iso, "mode": mode,
369
416
  "pct": health["health_pct"],
@@ -373,7 +420,7 @@ def run_tick(
373
420
  }
374
421
  append_history(state_dir, entry)
375
422
 
376
- # 7. Rebalance mode logic
423
+ # 8. Rebalance mode logic
377
424
  if mode == "rebalance":
378
425
  # Valuation gate: never auto-lever/de-lever on incomplete or untrustworthy data
379
426
  # (missing RedStone feed → unpriced position → wrong equity/debt/health). Escalate
@@ -399,6 +446,7 @@ def run_tick(
399
446
  position_type = strategy.get("position", "")
400
447
  market = strategy.get("market", "avax-usdc")
401
448
  side = strategy.get("side", "short")
449
+ swap_target = strategy.get("swap_target", "")
402
450
  low, high = target_range[0], target_range[1]
403
451
 
404
452
  pct = health["health_pct"]
@@ -465,16 +513,73 @@ def run_tick(
465
513
  return result
466
514
 
467
515
  if repay_amt > raw_usdc:
468
- # Need to withdraw from position first escalate
469
- write_escalation(state_dir, "repay-no-usdc", {
470
- "reason": "repay_needs_position_close",
471
- "repay_needed": repay_amt,
472
- "raw_usdc": raw_usdc,
473
- "health_pct": pct,
474
- "label": label,
475
- })
476
- result["action"] = "escalate (need close)"
477
- return result
516
+ # Build supply_rows from defi_data for potential swap source
517
+ supply_rows = []
518
+ groups = defi_data.get("groups", [])
519
+ if groups:
520
+ for g in groups:
521
+ supply_rows.extend(g.get("supplied", []))
522
+ else:
523
+ supply_rows.extend(defi_data.get("supplied", []))
524
+
525
+ # Need more USDC — try to swap from swap_target if configured
526
+ if swap_target:
527
+ for s in list(supply_rows):
528
+ sym = s.get("symbol", "")
529
+ usd_val = s.get("usd", 0) or 0
530
+ if sym.upper() == swap_target.upper() and usd_val > 1:
531
+ raw_amt = s.get("amount", 0)
532
+ swap_token_amt = raw_amt * 0.95
533
+ if swap_token_amt < 0.001:
534
+ break
535
+ try:
536
+ sr = subprocess.run(
537
+ [sys.executable, tool_path, "swap",
538
+ "--from", swap_target,
539
+ "--to", "USDC",
540
+ "--amount", f"{swap_token_amt:.6f}",
541
+ "--slippage", "1.0",
542
+ "--execute"],
543
+ capture_output=True, text=True, timeout=180,
544
+ )
545
+ if sr.returncode == 0:
546
+ result["swap"] = f"swapped {swap_token_amt:.4f} {swap_target} -> USDC"
547
+ else:
548
+ write_escalation(state_dir, "repay-swap-failed", {
549
+ "reason": "repay_swap_failed",
550
+ "swap_source": swap_target,
551
+ "swap_amount": swap_token_amt,
552
+ "stderr": sr.stderr[:200],
553
+ "health_pct": pct,
554
+ "label": label,
555
+ })
556
+ result["error"] = f"swap failed: {sr.stderr[:200]}"
557
+ result["action"] = "escalate (swap failed)"
558
+ return result
559
+ except Exception as e:
560
+ result["error"] = f"swap error: {e}"
561
+ return result
562
+ break
563
+ else:
564
+ write_escalation(state_dir, "repay-no-usdc", {
565
+ "reason": "repay_needs_position_close",
566
+ "repay_needed": repay_amt,
567
+ "raw_usdc": raw_usdc,
568
+ "health_pct": pct,
569
+ "label": label,
570
+ })
571
+ result["action"] = "escalate (need close)"
572
+ return result
573
+ else:
574
+ write_escalation(state_dir, "repay-no-usdc", {
575
+ "reason": "repay_needs_position_close",
576
+ "repay_needed": repay_amt,
577
+ "raw_usdc": raw_usdc,
578
+ "health_pct": pct,
579
+ "label": label,
580
+ })
581
+ result["action"] = "escalate (need close)"
582
+ return result
478
583
 
479
584
  if dry_run:
480
585
  result["action"] = f"would repay ${repay_amt:.2f} USDC"
@@ -490,6 +595,7 @@ def run_tick(
490
595
  if r.returncode == 0:
491
596
  cooldown_file.write_text(str(int(time.time())))
492
597
  result["action"] = f"repaid ${repay_amt:.2f}"
598
+ _notify(f"🔄 Rebalance: {label} repaid ${repay_amt:.2f} USDC (health was {pct}%)")
493
599
  else:
494
600
  result["error"] = f"repay failed: {r.stderr[:200]}"
495
601
  except Exception as e:
@@ -527,99 +633,126 @@ def run_tick(
527
633
  result["error"] = f"borrow error: {e}"
528
634
  return result
529
635
 
530
- # Deploy into whatever positions are open (detected dynamically from defi_data)
531
- has_gmx = health.get("has_gmx", False)
532
- has_lb = health.get("has_lb", False)
533
- has_aero = health.get("has_aero", False)
534
- open_positions = []
535
- if has_gmx: open_positions.append("gmx")
536
- if has_lb: open_positions.append("lb")
537
- if has_aero: open_positions.append("aero")
538
-
539
- if not open_positions:
540
- # No open positions — just borrow and leave as USDC (or deploy to default)
541
- result["action"] = f"borrowed ${borrow_amt:.2f} (no positions to deploy into)"
542
- cooldown_file.write_text(str(int(time.time())))
636
+ # If swap_target is set, deploy by swapping borrowed USDC to that token
637
+ if swap_target and swap_target.upper() != "USDC":
638
+ split_amt = borrow_amt
639
+ try:
640
+ sr = subprocess.run(
641
+ [sys.executable, tool_path, "swap",
642
+ "--from", "USDC",
643
+ "--to", swap_target,
644
+ "--amount", f"{split_amt:.2f}",
645
+ "--slippage", "1.0",
646
+ "--execute"],
647
+ capture_output=True, text=True, timeout=180,
648
+ )
649
+ if sr.returncode == 0:
650
+ cooldown_file.write_text(str(int(time.time())))
651
+ result["action"] = f"borrowed ${borrow_amt:.2f}, swapped {split_amt:.2f} USDC -> {swap_target}"
652
+ _notify(f"🔄 Rebalance: {label} borrowed ${borrow_amt:.2f} USDC \u2192 swapped to {swap_target} (health was {pct}%)")
653
+ else:
654
+ result["warning"] = f"swap to {swap_target} failed after borrow: {sr.stderr[:200]}"
655
+ cooldown_file.write_text(str(int(time.time())))
656
+ except Exception as e:
657
+ result["error"] = f"borrow+swap error: {e}"
658
+ cooldown_file.write_text(str(int(time.time())))
659
+
543
660
  else:
544
- # Split borrow amount proportionally across open positions
545
- split_amt = borrow_amt / len(open_positions)
546
- deployed_ok = 0
547
- deployed_fail = 0
548
-
549
- for pos_type in open_positions:
550
- if pos_type == "gmx":
551
- # Use market/side from strategy as hint, fall back to sensible defaults
552
- mkt = strategy.get("market", "avax-usdc") if tool_path else "avax-usdc"
553
- sd = strategy.get("side", "long") if tool_path else "long"
554
- try:
555
- r = subprocess.run(
556
- [sys.executable, tool_path, "gmx-deposit",
557
- "--market", mkt, "--amount", f"{split_amt:.2f}",
558
- "--side", sd, "--fee-buffer", "1.5", "--execute"],
559
- capture_output=True, text=True, timeout=120,
560
- )
561
- if r.returncode == 0:
562
- deployed_ok += 1
563
- else:
564
- result["warning"] = f"gmx deposit failed: {r.stderr[:200]}"
565
- deployed_fail += 1
566
- except Exception as e:
567
- result["error"] = f"gmx deposit error: {e}"
568
- deployed_fail += 1
569
-
570
- elif pos_type == "lb":
571
- # Detect LB pair from defi data (look for "TraderJoe V2 LB" group)
572
- lb_pairs = []
573
- for g in defi_data.get("groups", []):
574
- if g.get("type") == "TraderJoe V2 LB":
575
- for item in g.get("items", []):
576
- label = item.get("label", "")
577
- m = re.match(r'\[([^\]]+)\]', label)
578
- if m:
579
- lb_pairs.append(m.group(1))
580
-
581
- # Skip if tool doesn't support lb-add (degenprime)
582
- tool_bn = os.path.basename(tool_path) if tool_path else ""
583
- if "degenprime" in tool_bn:
584
- result["action"] = f"lb-add not available on degenprime — leaving ${split_amt:.2f} as USDC"
585
- deployed_fail += 1
586
- elif not lb_pairs:
587
- result["warning"] = f"has_lb=True but no LB pair found in defi data — leaving ${split_amt:.2f} as USDC"
588
- deployed_fail += 1
589
- else:
590
- pair_key = lb_pairs[0]
661
+ # Deploy into whatever positions are open (detected dynamically from defi_data)
662
+ has_gmx = health.get("has_gmx", False)
663
+ has_lb = health.get("has_lb", False)
664
+ has_aero = health.get("has_aero", False)
665
+ open_positions = []
666
+ if has_gmx: open_positions.append("gmx")
667
+ if has_lb: open_positions.append("lb")
668
+ if has_aero: open_positions.append("aero")
669
+
670
+ if not open_positions:
671
+ # No open positions — just borrow and leave as USDC (or deploy to default)
672
+ result["action"] = f"borrowed ${borrow_amt:.2f} (no positions to deploy into)"
673
+ cooldown_file.write_text(str(int(time.time())))
674
+ _notify(f"🔄 Rebalance: {label} borrowed ${borrow_amt:.2f} USDC (no positions to deploy, health was {pct}%)")
675
+ else:
676
+ # Split borrow amount proportionally across open positions
677
+ split_amt = borrow_amt / len(open_positions)
678
+ deployed_ok = 0
679
+ deployed_fail = 0
680
+
681
+ for pos_type in open_positions:
682
+ if pos_type == "gmx":
683
+ # Use market/side from strategy as hint, fall back to sensible defaults
684
+ mkt = strategy.get("market", "avax-usdc") if tool_path else "avax-usdc"
685
+ sd = strategy.get("side", "long") if tool_path else "long"
591
686
  try:
592
687
  r = subprocess.run(
593
- [sys.executable, tool_path, "lb-add",
594
- "--pair", pair_key,
595
- "--amount-x", "0",
596
- "--amount-y", f"{split_amt:.2f}",
597
- "--shape", "spot",
598
- "--range", "15",
599
- "--execute"],
688
+ [sys.executable, tool_path, "gmx-deposit",
689
+ "--market", mkt, "--amount", f"{split_amt:.2f}",
690
+ "--side", sd, "--fee-buffer", "1.5", "--execute"],
600
691
  capture_output=True, text=True, timeout=120,
601
692
  )
602
693
  if r.returncode == 0:
603
694
  deployed_ok += 1
604
695
  else:
605
- result["warning"] = f"lb-add failed: {r.stderr[:200]}"
696
+ result["warning"] = f"gmx deposit failed: {r.stderr[:200]}"
606
697
  deployed_fail += 1
607
698
  except Exception as e:
608
- result["error"] = f"lb-add error: {e}"
699
+ result["error"] = f"gmx deposit error: {e}"
609
700
  deployed_fail += 1
610
701
 
611
- elif pos_type == "aero":
612
- # Aerodrome CL: degenprime has read-only aerodrome-positions,
613
- # but no deposit/withdraw commands yet (write paths deferred to
614
- # v2 on-chain signatures vary by Aerodrome version).
615
- # Use `degenprime aerodrome-positions` to list your NFT tokenIds.
616
- result["action"] = f"aero deposit not yet supported (read-only via aerodrome-positions, writes deferred to v2) — leaving ${split_amt:.2f} as USDC"
702
+ elif pos_type == "lb":
703
+ # Detect LB pair from defi data (look for "TraderJoe V2 LB" group)
704
+ lb_pairs = []
705
+ for g in defi_data.get("groups", []):
706
+ if g.get("type") == "TraderJoe V2 LB":
707
+ for item in g.get("items", []):
708
+ label = item.get("label", "")
709
+ m = re.match(r'\[([^\]]+)\]', label)
710
+ if m:
711
+ lb_pairs.append(m.group(1))
712
+
713
+ # Skip if tool doesn't support lb-add (degenprime)
714
+ tool_bn = os.path.basename(tool_path) if tool_path else ""
715
+ if "degenprime" in tool_bn:
716
+ result["action"] = f"lb-add not available on degenprime — leaving ${split_amt:.2f} as USDC"
717
+ deployed_fail += 1
718
+ elif not lb_pairs:
719
+ result["warning"] = f"has_lb=True but no LB pair found in defi data — leaving ${split_amt:.2f} as USDC"
720
+ deployed_fail += 1
721
+ else:
722
+ pair_key = lb_pairs[0]
723
+ try:
724
+ r = subprocess.run(
725
+ [sys.executable, tool_path, "lb-add",
726
+ "--pair", pair_key,
727
+ "--amount-x", "0",
728
+ "--amount-y", f"{split_amt:.2f}",
729
+ "--shape", "spot",
730
+ "--range", "15",
731
+ "--execute"],
732
+ capture_output=True, text=True, timeout=120,
733
+ )
734
+ if r.returncode == 0:
735
+ deployed_ok += 1
736
+ else:
737
+ result["warning"] = f"lb-add failed: {r.stderr[:200]}"
738
+ deployed_fail += 1
739
+ except Exception as e:
740
+ result["error"] = f"lb-add error: {e}"
741
+ deployed_fail += 1
617
742
 
618
- if deployed_ok > 0:
619
- cooldown_file.write_text(str(int(time.time())))
620
- result["action"] = f"borrowed ${borrow_amt:.2f}, deployed ${split_amt:.2f} to {deployed_ok} position(s)"
621
- else:
622
- result["warning"] = f"borrow ok but all deposits failed"
743
+ elif pos_type == "aero":
744
+ # Aerodrome CL: degenprime has read-only aerodrome-positions,
745
+ # but no deposit/withdraw commands yet (write paths deferred to
746
+ # v2 — on-chain signatures vary by Aerodrome version).
747
+ # Use `degenprime aerodrome-positions` to list your NFT tokenIds.
748
+ result["action"] = f"aero deposit not yet supported (read-only via aerodrome-positions, writes deferred to v2) — leaving ${split_amt:.2f} as USDC"
749
+
750
+ if deployed_ok > 0:
751
+ cooldown_file.write_text(str(int(time.time())))
752
+ result["action"] = f"borrowed ${borrow_amt:.2f}, deployed ${split_amt:.2f} to {deployed_ok} position(s)"
753
+ _notify(f"🔄 Rebalance: {label} borrowed ${borrow_amt:.2f} USDC, deployed {split_amt:.2f} to {deployed_ok} position(s) (health was {pct}%)")
754
+ else:
755
+ result["warning"] = f"borrow ok but all deposits failed"
623
756
 
624
757
  return result
625
758
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.7.4
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.4 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.4"
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