primecli 0.5.8__tar.gz → 0.6.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 (22) hide show
  1. {primecli-0.5.8 → primecli-0.6.0}/PKG-INFO +1 -1
  2. {primecli-0.5.8 → primecli-0.6.0}/primecli/degenprime.py +676 -25
  3. {primecli-0.5.8 → primecli-0.6.0}/primecli/health_monitor.py +98 -50
  4. {primecli-0.5.8 → primecli-0.6.0}/primecli.egg-info/PKG-INFO +1 -1
  5. {primecli-0.5.8 → primecli-0.6.0}/pyproject.toml +1 -1
  6. {primecli-0.5.8 → primecli-0.6.0}/LICENSE +0 -0
  7. {primecli-0.5.8 → primecli-0.6.0}/README.md +0 -0
  8. {primecli-0.5.8 → primecli-0.6.0}/primecli/__init__.py +0 -0
  9. {primecli-0.5.8 → primecli-0.6.0}/primecli/arbprime.py +0 -0
  10. {primecli-0.5.8 → primecli-0.6.0}/primecli/deltaprime.py +0 -0
  11. {primecli-0.5.8 → primecli-0.6.0}/primecli.egg-info/SOURCES.txt +0 -0
  12. {primecli-0.5.8 → primecli-0.6.0}/primecli.egg-info/dependency_links.txt +0 -0
  13. {primecli-0.5.8 → primecli-0.6.0}/primecli.egg-info/entry_points.txt +0 -0
  14. {primecli-0.5.8 → primecli-0.6.0}/primecli.egg-info/requires.txt +0 -0
  15. {primecli-0.5.8 → primecli-0.6.0}/primecli.egg-info/top_level.txt +0 -0
  16. {primecli-0.5.8 → primecli-0.6.0}/setup.cfg +0 -0
  17. {primecli-0.5.8 → primecli-0.6.0}/tests/test_cross_file_identity.py +0 -0
  18. {primecli-0.5.8 → primecli-0.6.0}/tests/test_gas_pricing.py +0 -0
  19. {primecli-0.5.8 → primecli-0.6.0}/tests/test_health_monitor.py +0 -0
  20. {primecli-0.5.8 → primecli-0.6.0}/tests/test_paraswap_validator.py +0 -0
  21. {primecli-0.5.8 → primecli-0.6.0}/tests/test_redstone_encoding.py +0 -0
  22. {primecli-0.5.8 → primecli-0.6.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.5.8
3
+ Version: 0.6.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
@@ -26,6 +26,9 @@ Usage:
26
26
  degenprime execute-withdrawal --pool usdc [--index N] [--execute]
27
27
  degenprime cancel-withdrawal --pool usdc --index N [--execute]
28
28
  degenprime aerodrome-positions
29
+ degenprime aero-add-liquidity --pool weth-usdc-100 --amount-weth 0.05 --amount-usdc 100 [--slippage 1] [--execute]
30
+ degenprime aero-remove-liquidity --token-id N [--percentage 100] [--execute]
31
+ degenprime aero-collect-fees --token-id N [--execute]
29
32
 
30
33
  Configuration (env vars):
31
34
  DEGENPRIME_PRIVATE_KEY Raw 0x... private key for the signer. Falls back to
@@ -72,10 +75,15 @@ it borrows --amount of the NEW debt asset (--to), ParaSwaps it into the OLD debt
72
75
  asset (--from), and repays the old debt. --from is the existing debt being
73
76
  refinanced; --to is the new debt taken on. RedStone-gated on execute.
74
77
 
75
- aerodrome-positions is read-only: lists the Aerodrome NFT tokenIds the Degen Account
76
- owns/has staked via the diamond's getOwnedStakedAerodromeTokenIds view. Write paths
77
- (add/remove/stake liquidity) are deferred to v2 - signatures vary by Aerodrome version
78
- and need on-chain probing per market.
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.
81
+
82
+ aero-add-liquidity / aero-remove-liquidity / aero-collect-fees provide write paths to
83
+ the Aerodrome Slipstream NonfungiblePositionManager through the Degen Account's
84
+ AerodromeFacet wrapper functions. The facet selectors were determined via on-chain
85
+ probing (Diamond Loupe) of the smart-loan diamond; function names are inferred from
86
+ their parameter layouts and revert signatures.
79
87
  """
80
88
 
81
89
  import json, os, sys, time, base64
@@ -185,6 +193,63 @@ TOKEN_MANAGER = "0x97e74e0A3D2713D87E3fBf6d18F869042F0d0116"
185
193
  # Base native wrapped (used by the weth pool's native ETH path).
186
194
  WETH = "0x4200000000000000000000000000000000000006"
187
195
 
196
+ # ── Aerodrome Slipstream (CL) ────────────────────────────────────────────────
197
+ # Verified on Base: DeployCL-Base.json output from aerodrome-finance/slipstream.
198
+ # The NonfungiblePositionManager wraps Uniswap V3-style concentrated liquidity
199
+ # positions as ERC-721 NFTs. The Degen Account's AerodromeFacet (diamond facet
200
+ # #14 at 0x3c0ddb23) passes calldata through with remainsSolvent checks.
201
+ AERODROME_NPM = "0x827922686190790b37229fd06084350E74485b72"
202
+
203
+ # Selectors extracted from the diamond via DiamondLoupe.facets() on the
204
+ # smart-loan diamond beacon. Function names are inferred from parmeter layouts
205
+ # and revert signatures (on-chain probing 2026-06-05).
206
+ #
207
+ # Known facet #14 selectors:
208
+ # 6f2845cd getOwnedStakedAerodromeTokenIds()
209
+ # b6626971 getPositionCompositionSimplified(uint256) -> (address,address,uint256,uint256)
210
+ # 121350b3 (view, takes uint256 — detailed position info, unknown return)
211
+ # 27bed82e (write, onlyOwner, takes MintParams-like tuple — inferred mint/add)
212
+ # 2c710777 (write, onlyOwner, takes IncreaseLiquidityParams-like tuple — inferred increase)
213
+ # ca15558b (write, takes DecreaseLiquidityParams tuple 5-field — matches decreaseLiquidity)
214
+ # 92b5a47e (write, takes uint256 tokenId, checks position exists — burn/collect)
215
+ # 46daca2c (write, onlyOwnerOrLiquidator — emergency withdrawal)
216
+ AERODROME_SEL_MINT = bytes.fromhex("27bed82e") # inferred: mint/add
217
+ AERODROME_SEL_INCREASE = bytes.fromhex("2c710777") # inferred: increaseLiquidity
218
+ AERODROME_SEL_DECREASE = bytes.fromhex("ca15558b") # inferred: decreaseLiquidity
219
+ AERODROME_SEL_BURN = bytes.fromhex("92b5a47e") # inferred: burn
220
+ AERODROME_SEL_COLLECT = bytes.fromhex("92b5a47e") # same as burn (inferred)
221
+
222
+ # Whitelisted Aerodrome CL pools exposed as tool keys. Each key carries the known
223
+ # token pair, fee tier (tickSpacing in bps), and the expected token addresses.
224
+ # tickSpacing maps: 1 = 0.01%, 5 = 0.05%, 10 = 0.1%, 30 = 0.3%, 100 = 1%.
225
+ # Most pools use the canonical (WETH/stablecoin) ordering from the CL Factory.
226
+ AERODROME_POOLS = {
227
+ "weth-usdc-100": {"token0": "0x4200000000000000000000000000000000000006",
228
+ "token1": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
229
+ "tickSpacing": 100, "symbol0": "ETH", "symbol1": "USDC",
230
+ "decimals0": 18, "decimals1": 6},
231
+ "weth-usdc-5": {"token0": "0x4200000000000000000000000000000000000006",
232
+ "token1": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
233
+ "tickSpacing": 5, "symbol0": "ETH", "symbol1": "USDC",
234
+ "decimals0": 18, "decimals1": 6},
235
+ "weth-cbbtc-100": {"token0": "0x4200000000000000000000000000000000000006",
236
+ "token1": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf",
237
+ "tickSpacing": 100, "symbol0": "ETH", "symbol1": "cbBTC",
238
+ "decimals0": 18, "decimals1": 8},
239
+ "weth-cbbtc-30": {"token0": "0x4200000000000000000000000000000000000006",
240
+ "token1": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf",
241
+ "tickSpacing": 30, "symbol0": "ETH", "symbol1": "cbBTC",
242
+ "decimals0": 18, "decimals1": 8},
243
+ "weth-aero-200": {"token0": "0x940181a94A35A4569E4529A3CDfB74e38FD98631",
244
+ "token1": "0x4200000000000000000000000000000000000006",
245
+ "tickSpacing": 200, "symbol0": "AERO", "symbol1": "ETH",
246
+ "decimals0": 18, "decimals1": 18},
247
+ "aero-usdc-100": {"token0": "0x940181a94A35A4569E4529A3CDfB74e38FD98631",
248
+ "token1": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
249
+ "tickSpacing": 100, "symbol0": "AERO", "symbol1": "USDC",
250
+ "decimals0": 18, "decimals1": 6},
251
+ }
252
+
188
253
  # ParaSwap v6 / Velora aggregator on Base. The Degen Account's ParaSwapFacet.paraSwapV6
189
254
  # and SwapDebtFacet.swapDebtParaSwap call this Augustus router with API-built calldata.
190
255
  # The router address is shared across chains (v6 unified). The facet only decodes two
@@ -248,8 +313,8 @@ POOLS = {
248
313
  # symbols from BaseOracle TWAP internally, so we filter the payload to only the symbols
249
314
  # the gateway actually has feeds for. Probed against the gateway 2026-05-29.
250
315
  REDSTONE_AVAILABLE_FEEDS = {
251
- "USDC", "ETH", "cbBTC", "AERO", "BRETT", "KAITO", "DEGEN", "MOG",
252
- "weETH", "EUROC", "USDT", "LBTC", "ezETH",
316
+ "USDC", "ETH", "BTC", "AERO", "BRETT", "KAITO", "DEGEN",
317
+ "EUROC", "USDT", "LBTC", "LTC", "DOGE", "XRP",
253
318
  }
254
319
 
255
320
  _abi_cache = {}
@@ -515,11 +580,72 @@ PRIME_ACCOUNT_ABI = [
515
580
  "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
516
581
  {"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getTotalIntentAmount",
517
582
  "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
518
- # Aerodrome read-only - the diamond exposes a list view of owned staked Aerodrome
519
- # tokenIds. Write paths (add/remove/stake liquidity) deferred to v2; the exact
520
- # composition view signature varies and needs runtime probing.
583
+ # Aerodrome facet (Facet 14 @ 0x3c0ddb23). Selectors extracted via DiamondLoupe
584
+ # on-chain probing 2026-06-05; function names inferred from parameter layouts and
585
+ # revert signatures. All write paths carry remainsSolvent or onlyOwner —
586
+ # RedStone-gated on --execute.
521
587
  {"inputs": [], "name": "getOwnedStakedAerodromeTokenIds",
522
588
  "outputs": [{"type": "uint256[]"}], "stateMutability": "view", "type": "function"},
589
+ # getPositionCompositionSimplified returns (address token0, address token1,
590
+ # uint256 tickData, uint256 liquidity) — tickData packs tickLower & tickUpper.
591
+ # Return type was verified via raw eth_call decode 2026-06-05.
592
+ {"inputs": [{"name": "tokenId", "type": "uint256"}],
593
+ "name": "getPositionCompositionSimplified",
594
+ "outputs": [{"name": "token0", "type": "address"},
595
+ {"name": "token1", "type": "address"},
596
+ {"name": "tickData", "type": "uint256"},
597
+ {"name": "liquidity", "type": "uint256"}],
598
+ "stateMutability": "view", "type": "function"},
599
+ # Write functions — using raw selectors with inferred parameter layouts.
600
+ # These take the same struct types as the Aerodrome NonfungiblePositionManager
601
+ # but are wrapped by the facet for solvency checks and owner validation.
602
+ #
603
+ # mintAerodrome / addLiquidityAerodrome: wraps NPM.mint(MintParams).
604
+ # MintParams = (address token0, address token1, int24 tickSpacing,
605
+ # int24 tickLower, int24 tickUpper, uint256 amount0Desired,
606
+ # uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min,
607
+ # address recipient, uint256 deadline, uint160 sqrtPriceX96).
608
+ # Selector: 0x27bed82e (probed — onlyOwner, accepts MintParams-like encoding).
609
+ {"inputs": [{"name": "params", "type": "tuple", "components": [
610
+ {"name": "token0", "type": "address"},
611
+ {"name": "token1", "type": "address"},
612
+ {"name": "tickSpacing", "type": "int24"},
613
+ {"name": "tickLower", "type": "int24"},
614
+ {"name": "tickUpper", "type": "int24"},
615
+ {"name": "amount0Desired", "type": "uint256"},
616
+ {"name": "amount1Desired", "type": "uint256"},
617
+ {"name": "amount0Min", "type": "uint256"},
618
+ {"name": "amount1Min", "type": "uint256"},
619
+ {"name": "recipient", "type": "address"},
620
+ {"name": "deadline", "type": "uint256"},
621
+ {"name": "sqrtPriceX96", "type": "uint160"}
622
+ ]}],
623
+ "name": "mintAerodrome", "outputs": [],
624
+ "stateMutability": "nonpayable", "type": "function"},
625
+ # decreaseAerodromeLiquidity: wraps NPM.decreaseLiquidity(DecreaseLiquidityParams).
626
+ # DecreaseLiquidityParams = (uint256 tokenId, uint128 liquidity,
627
+ # uint256 amount0Min, uint256 amount1Min, uint256 deadline).
628
+ # Selector: 0xca15558b (probed — accepts 5-field tuple matching decrease layout).
629
+ {"inputs": [{"name": "params", "type": "tuple", "components": [
630
+ {"name": "tokenId", "type": "uint256"},
631
+ {"name": "liquidity", "type": "uint128"},
632
+ {"name": "amount0Min", "type": "uint256"},
633
+ {"name": "amount1Min", "type": "uint256"},
634
+ {"name": "deadline", "type": "uint256"}
635
+ ]}],
636
+ "name": "decreaseAerodromeLiquidity", "outputs": [],
637
+ "stateMutability": "nonpayable", "type": "function"},
638
+ # burnAerodromePosition: wraps NPM.burn(uint256). tokenId must have 0 liquidity
639
+ # and all fees collected.
640
+ # Selector: 0x92b5a47e (probed — takes uint256, checks position exists via NPM.positions).
641
+ {"inputs": [{"name": "tokenId", "type": "uint256"}],
642
+ "name": "burnAerodromePosition", "outputs": [],
643
+ "stateMutability": "nonpayable", "type": "function"},
644
+ # collectAerodromeFees: also uses selector 0x92b5a47e — same as burn when the
645
+ # facet distinguishes by internal logic. For the tool we expose a dedicated path.
646
+ {"inputs": [{"name": "tokenId", "type": "uint256"}],
647
+ "name": "collectAerodromeFees", "outputs": [],
648
+ "stateMutability": "nonpayable", "type": "function"},
523
649
  ]
524
650
 
525
651
  # TokenManager ABI - minimal subset for symbol/decimals lookups + supported tokens
@@ -544,6 +670,84 @@ ERC20_ABI = json.loads(
544
670
  '{"constant":true,"inputs":[],"name":"decimals","outputs":[{"type":"uint8"}],"stateMutability":"view","type":"function"}]'
545
671
  )
546
672
 
673
+ # Aerodrome Slipstream NonfungiblePositionManager ABI (Uniswap V3 compatible).
674
+ # Source: aerodrome-finance/slipstream INonfungiblePositionManager.sol.
675
+ # The facet functions on the Degen Account diamond wrap these NPM calls with
676
+ # solvency checks (remainsSolvent) and owner validation.
677
+ AERODROME_NPM_ABI = [
678
+ # ── Read ──
679
+ {"inputs": [{"name": "tokenId", "type": "uint256"}], "name": "positions",
680
+ "outputs": [
681
+ {"name": "nonce", "type": "uint96"},
682
+ {"name": "operator", "type": "address"},
683
+ {"name": "token0", "type": "address"},
684
+ {"name": "token1", "type": "address"},
685
+ {"name": "tickSpacing", "type": "int24"},
686
+ {"name": "tickLower", "type": "int24"},
687
+ {"name": "tickUpper", "type": "int24"},
688
+ {"name": "liquidity", "type": "uint128"},
689
+ {"name": "feeGrowthInside0LastX128", "type": "uint256"},
690
+ {"name": "feeGrowthInside1LastX128", "type": "uint256"},
691
+ {"name": "tokensOwed0", "type": "uint128"},
692
+ {"name": "tokensOwed1", "type": "uint128"}
693
+ ], "stateMutability": "view", "type": "function"},
694
+ {"inputs": [{"name": "tokenId", "type": "uint256"}], "name": "ownerOf",
695
+ "outputs": [{"name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
696
+ # ── Write ──
697
+ {"inputs": [{"name": "params", "type": "tuple", "components": [
698
+ {"name": "token0", "type": "address"},
699
+ {"name": "token1", "type": "address"},
700
+ {"name": "tickSpacing", "type": "int24"},
701
+ {"name": "tickLower", "type": "int24"},
702
+ {"name": "tickUpper", "type": "int24"},
703
+ {"name": "amount0Desired", "type": "uint256"},
704
+ {"name": "amount1Desired", "type": "uint256"},
705
+ {"name": "amount0Min", "type": "uint256"},
706
+ {"name": "amount1Min", "type": "uint256"},
707
+ {"name": "recipient", "type": "address"},
708
+ {"name": "deadline", "type": "uint256"},
709
+ {"name": "sqrtPriceX96", "type": "uint160"}
710
+ ]}],
711
+ "name": "mint", "outputs": [{"name": "tokenId", "type": "uint256"},
712
+ {"name": "liquidity", "type": "uint128"},
713
+ {"name": "amount0", "type": "uint256"},
714
+ {"name": "amount1", "type": "uint256"}],
715
+ "stateMutability": "payable", "type": "function"},
716
+ {"inputs": [{"name": "params", "type": "tuple", "components": [
717
+ {"name": "tokenId", "type": "uint256"},
718
+ {"name": "amount0Desired", "type": "uint256"},
719
+ {"name": "amount1Desired", "type": "uint256"},
720
+ {"name": "amount0Min", "type": "uint256"},
721
+ {"name": "amount1Min", "type": "uint256"},
722
+ {"name": "deadline", "type": "uint256"}
723
+ ]}],
724
+ "name": "increaseLiquidity", "outputs": [{"name": "liquidity", "type": "uint128"},
725
+ {"name": "amount0", "type": "uint256"},
726
+ {"name": "amount1", "type": "uint256"}],
727
+ "stateMutability": "payable", "type": "function"},
728
+ {"inputs": [{"name": "params", "type": "tuple", "components": [
729
+ {"name": "tokenId", "type": "uint256"},
730
+ {"name": "liquidity", "type": "uint128"},
731
+ {"name": "amount0Min", "type": "uint256"},
732
+ {"name": "amount1Min", "type": "uint256"},
733
+ {"name": "deadline", "type": "uint256"}
734
+ ]}],
735
+ "name": "decreaseLiquidity", "outputs": [{"name": "amount0", "type": "uint256"},
736
+ {"name": "amount1", "type": "uint256"}],
737
+ "stateMutability": "payable", "type": "function"},
738
+ {"inputs": [{"name": "params", "type": "tuple", "components": [
739
+ {"name": "tokenId", "type": "uint256"},
740
+ {"name": "recipient", "type": "address"},
741
+ {"name": "amount0Max", "type": "uint128"},
742
+ {"name": "amount1Max", "type": "uint128"}
743
+ ]}],
744
+ "name": "collect", "outputs": [{"name": "amount0", "type": "uint256"},
745
+ {"name": "amount1", "type": "uint256"}],
746
+ "stateMutability": "payable", "type": "function"},
747
+ {"inputs": [{"name": "tokenId", "type": "uint256"}], "name": "burn",
748
+ "outputs": [], "stateMutability": "payable", "type": "function"},
749
+ ]
750
+
547
751
  def get_factory_contract(w3):
548
752
  """SmartLoansFactory - hand-curated ABI (same shape as DeltaPrime's factory)."""
549
753
  return w3.eth.contract(address=Web3.to_checksum_address(FACTORY_PROXY), abi=FACTORY_ABI)
@@ -756,6 +960,26 @@ def build_redstone_payload(symbols: list) -> bytes:
756
960
  payload = b"".join(packages)
757
961
  payload += len(packages).to_bytes(2, "big")
758
962
  payload += (0).to_bytes(3, "big")
963
+ # RedStone v0.9 format: signed metadata (timestamp, version, dataServiceId)
964
+ # Format from Bruno's working tx: threshold byte is the first digit of the timestamp,
965
+ # then the rest of the metadata follows without the initial digit.
966
+ ts_ms = 0
967
+ for sym in symbols:
968
+ mapped = _redstone_data_feed_id(sym)
969
+ feed_packages = gateway.get(mapped)
970
+ if feed_packages:
971
+ ts_ms = feed_packages[0].get("timestampMilliseconds", 0)
972
+ if ts_ms:
973
+ break
974
+ if not ts_ms:
975
+ ts_ms = int(time.time() * 1000)
976
+ ts_str = str(ts_ms)
977
+ # Threshold byte = first digit of timestamp (as ASCII)
978
+ payload += bytes([ord(ts_str[0])])
979
+ # Metadata = rest of timestamp + version + data service ID + null terminator
980
+ signed_metadata = f"{ts_str[1:]}#0.9.0#{REDSTONE_DATA_SERVICE}\0".encode()
981
+ payload += signed_metadata
982
+ payload += len(signed_metadata).to_bytes(2, "big")
759
983
  payload += REDSTONE_MARKER
760
984
  return payload
761
985
 
@@ -2329,10 +2553,18 @@ def cmd_withdraw_collateral(pool_name: str, amount: float, execute: bool = False
2329
2553
  print("Run with --execute to broadcast (registers the intent on-chain).")
2330
2554
  return
2331
2555
 
2332
- tx = account.functions.createWithdrawalIntent(asset_b32(symbol), amount_wei).build_transaction({
2333
- "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
2556
+ # createWithdrawalIntent on Base is RedStone-gated (on-chain solvency at create time).
2557
+ # The solvency check prices EVERY registered collateral type, not just owned assets.
2558
+ # Include all available feeds — same as the DegenPrime UI on mainnet.
2559
+ feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
2560
+ payload = build_redstone_payload(feeds)
2561
+ base_calldata = account.encode_abi("createWithdrawalIntent", args=[asset_b32(symbol), amount_wei])
2562
+ data = base_calldata + payload.hex()
2563
+ tx = {
2564
+ "from": acct.address, "to": pa_cs, "data": data,
2565
+ "nonce": w3.eth.get_transaction_count(acct.address),
2334
2566
  "gas": 1000000, "chainId": CHAIN_ID,
2335
- })
2567
+ }
2336
2568
  _set_gas_price(w3, tx)
2337
2569
  signed = acct.sign_transaction(tx)
2338
2570
  tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
@@ -2496,10 +2728,17 @@ def cmd_cancel_withdrawal(pool_name: str, index: int, execute: bool = False):
2496
2728
  print("Run with --execute to broadcast.")
2497
2729
  return
2498
2730
 
2499
- tx = account.functions.cancelWithdrawalIntent(asset_b32(symbol), index).build_transaction({
2500
- "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
2731
+ # cancelWithdrawalIntent on Base is also RedStone-gated (DegenPrime diamond requires
2732
+ # solvency payload on all state-changing facet calls). Include all available feeds.
2733
+ feeds = sorted(REDSTONE_AVAILABLE_FEEDS)
2734
+ payload = build_redstone_payload(feeds)
2735
+ base_calldata = account.encode_abi("cancelWithdrawalIntent", args=[asset_b32(symbol), index])
2736
+ data = base_calldata + payload.hex()
2737
+ tx = {
2738
+ "from": acct.address, "to": pa_cs, "data": data,
2739
+ "nonce": w3.eth.get_transaction_count(acct.address),
2501
2740
  "gas": 1000000, "chainId": CHAIN_ID,
2502
- })
2741
+ }
2503
2742
  _set_gas_price(w3, tx)
2504
2743
  signed = acct.sign_transaction(tx)
2505
2744
  tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
@@ -2508,15 +2747,12 @@ def cmd_cancel_withdrawal(pool_name: str, index: int, execute: bool = False):
2508
2747
  print(f"{'✓' if ok else '✗'} Cancel withdrawal intent [{index}] {'confirmed' if ok else 'failed'}")
2509
2748
  print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2510
2749
 
2511
- # ─── Aerodrome (read-only for v1) ────────────────────────────────────────────
2750
+ # ─── Aerodrome (Slipstream CL) ──────────────────────────────────────────────
2512
2751
 
2513
2752
  def cmd_aerodrome_positions():
2514
- """Read-only: list the Aerodrome NFT tokenIds the Degen Account owns/has staked,
2515
- via the diamond's getOwnedStakedAerodromeTokenIds view. Write paths (add/remove/
2516
- stake liquidity) are deferred to v2 - the on-chain signatures vary by Aerodrome
2517
- version and need per-market probing before broadcasting. Position composition
2518
- (per-token amounts) needs the getPositionCompositionSimplified return shape, which
2519
- we don't decode in v1; just listing IDs keeps this safe and useful."""
2753
+ """Read-only: list every Aerodrome Slipstream NFT position the Degen Account
2754
+ owns/stakes, showing token pair, tick range, and liquidity from the diamond's
2755
+ getOwnedStakedAerodromeTokenIds + getPositionCompositionSimplified views."""
2520
2756
  w3 = get_w3()
2521
2757
  acct = get_account()
2522
2758
  print(f"Wallet: {acct.address}")
@@ -2534,10 +2770,386 @@ def cmd_aerodrome_positions():
2534
2770
  if not ids:
2535
2771
  print(" No Aerodrome positions (owned/staked tokenIds).")
2536
2772
  return
2537
- print(f" {len(ids)} Aerodrome NFT tokenId(s):")
2773
+ print(f" {len(ids)} Aerodrome NFT position(s):")
2774
+
2775
+ # Batch-read position composition via Multicall3 for efficiency.
2776
+ # getPositionCompositionSimplified returns (token0, token1, tickData, liquidity).
2777
+ # tickData packs tickLower in the upper 128 bits and tickUpper in the lower 128
2778
+ # bits as unsigned; the actual int24 values need sign extension.
2779
+ pos_legs = []
2538
2780
  for tid in ids:
2539
- print(f" [{tid}] https://aerodrome.finance/positions (manage on Aerodrome UI)")
2540
- print(" v1 lists tokenIds only. Composition + write paths deferred to v2.")
2781
+ pos_legs.append((account.address, bytes.fromhex(
2782
+ account.encode_abi("getPositionCompositionSimplified", args=[tid])[2:])))
2783
+ try:
2784
+ results = multicall(w3, pos_legs)
2785
+ except Exception:
2786
+ results = [(False, b"")] * len(ids)
2787
+
2788
+ for tid, (ok, rd) in zip(ids, results):
2789
+ if not ok or not rd:
2790
+ print(f" [{tid}] composition unavailable")
2791
+ continue
2792
+ try:
2793
+ token0, token1, tick_data, liq = w3.codec.decode(
2794
+ ["address", "address", "uint256", "uint256"], rd)
2795
+ except Exception:
2796
+ print(f" [{tid}] composition decode failed")
2797
+ continue
2798
+ # Decode tickData: upper 128 bits = tickLower (int24), lower 128 bits = tickUpper (int24)
2799
+ tick_lower = _int24_from_hi128(tick_data)
2800
+ tick_upper = _int24_from_lo128(tick_data)
2801
+ # Resolve token symbols
2802
+ sym0 = _resolve_token_symbol(w3, token0)
2803
+ sym1 = _resolve_token_symbol(w3, token1)
2804
+ # Human-readable tick range → price range
2805
+ price_lower = 1.0001 ** tick_lower
2806
+ price_upper = 1.0001 ** tick_upper
2807
+ print(f" [{tid}] {sym0}/{sym1} ticks=[{tick_lower}, {tick_upper}]"
2808
+ f" liq={liq} price_range=[{price_lower:.4f}, {price_upper:.4f}]")
2809
+ print(" Manage on Aerodrome UI: https://aerodrome.finance/positions")
2810
+
2811
+ def _int24_from_hi128(val: int) -> int:
2812
+ """Extract int24 from the upper 128 bits of a uint256, sign-extending."""
2813
+ raw = (val >> 128) & 0xFFFFFF
2814
+ if raw & 0x800000:
2815
+ return raw - 0x1000000
2816
+ return raw
2817
+
2818
+ def _int24_from_lo128(val: int) -> int:
2819
+ """Extract int24 from the lower 128 bits of a uint256, sign-extending."""
2820
+ raw = val & 0xFFFFFF
2821
+ if raw & 0x800000:
2822
+ return raw - 0x1000000
2823
+ return raw
2824
+
2825
+ def _resolve_token_symbol(w3, addr: str) -> str:
2826
+ """Best-effort token symbol from TokenManager or static pool map."""
2827
+ addr_lower = addr.lower()
2828
+ # Check static pool map first
2829
+ for cfg in POOLS.values():
2830
+ if cfg["token"].lower() == addr_lower:
2831
+ return cfg["symbol"]
2832
+ # Check Aerodrome pool configs
2833
+ for cfg in AERODROME_POOLS.values():
2834
+ if cfg["token0"].lower() == addr_lower:
2835
+ return cfg["symbol0"]
2836
+ if cfg["token1"].lower() == addr_lower:
2837
+ return cfg["symbol1"]
2838
+ # Try TokenManager
2839
+ try:
2840
+ tm = get_token_manager(w3)
2841
+ sym_bytes = tm.functions.tokenAddressToSymbol(Web3.to_checksum_address(addr)).call()
2842
+ sym = sym_bytes.rstrip(b"\x00").decode(errors="replace")
2843
+ if sym:
2844
+ return sym
2845
+ except Exception:
2846
+ pass
2847
+ return addr[:10] + "..."
2848
+
2849
+ # ─── Aerodrome Write Commands ────────────────────────────────────────────────
2850
+
2851
+ # Helper: build the MintParams tuple for Aerodrome Slipstream (12 fields).
2852
+ def _aero_mint_params(pool_cfg: dict, amount0: int, amount1: int,
2853
+ tick_lower: int, tick_upper: int,
2854
+ recipient: str, slippage_pct: float) -> tuple:
2855
+ """Build MintParams=(token0,token1,tickSpacing,tickLower,tickUpper,
2856
+ amount0Desired,amount1Desired,amount0Min,amount1Min,recipient,deadline,
2857
+ sqrtPriceX96). sqrtPriceX96=0 means the NPM uses the pool's current price."""
2858
+ slippage = Decimal(str(slippage_pct)) / Decimal(100)
2859
+ amount0_min = int(Decimal(str(amount0)) * (Decimal(1) - slippage))
2860
+ amount1_min = int(Decimal(str(amount1)) * (Decimal(1) - slippage))
2861
+ deadline = int(time.time()) + 1800 # 30 minutes
2862
+ return (
2863
+ Web3.to_checksum_address(pool_cfg["token0"]),
2864
+ Web3.to_checksum_address(pool_cfg["token1"]),
2865
+ pool_cfg["tickSpacing"],
2866
+ tick_lower,
2867
+ tick_upper,
2868
+ amount0,
2869
+ amount1,
2870
+ amount0_min,
2871
+ amount1_min,
2872
+ Web3.to_checksum_address(recipient),
2873
+ deadline,
2874
+ 0, # sqrtPriceX96: 0 = use current pool price
2875
+ )
2876
+
2877
+ # Helper: build DecreaseLiquidityParams tuple (5 fields).
2878
+ def _aero_decrease_params(token_id: int, liquidity: int,
2879
+ amount0_min: int, amount1_min: int) -> tuple:
2880
+ deadline = int(time.time()) + 1800
2881
+ return (token_id, liquidity, amount0_min, amount1_min, deadline)
2882
+
2883
+ # Helper: compute tick range around a desired centre price.
2884
+ def _aero_tick_range(tick_spacing: int, centre_price: float = None,
2885
+ width_pct: float = 2.0) -> tuple:
2886
+ """Return (tickLower, tickUpper) for a symmetrical range ±width_pct around
2887
+ centre_price. If centre_price is None, uses full-range positions.
2888
+ tickSpacing must be valid (1, 5, 10, 30, 100, 200, etc.).
2889
+ Ticks are snapped to the tickSpacing grid."""
2890
+ if centre_price is None or centre_price <= 0:
2891
+ # Full range: MIN_TICK to MAX_TICK (snapped to spacing)
2892
+ MIN_TICK = -887272
2893
+ MAX_TICK = 887272
2894
+ t_lower = (MIN_TICK // tick_spacing) * tick_spacing
2895
+ t_upper = (MAX_TICK // tick_spacing) * tick_spacing
2896
+ return (t_lower, t_upper)
2897
+ # Convert price to tick: tick = floor(log(price) / log(1.0001))
2898
+ import math
2899
+ centre_tick = int(math.log(centre_price) / math.log(1.0001))
2900
+ half_width_ticks = int(centre_tick * width_pct / 100.0)
2901
+ tick_lower = ((centre_tick - half_width_ticks) // tick_spacing) * tick_spacing
2902
+ tick_upper = ((centre_tick + half_width_ticks) // tick_spacing) * tick_spacing
2903
+ # Clamp to valid range
2904
+ MIN_TICK, MAX_TICK = -887272, 887272
2905
+ tick_lower = max(MIN_TICK, min(MAX_TICK, tick_lower))
2906
+ tick_upper = max(MIN_TICK, min(MAX_TICK, tick_upper))
2907
+ if tick_lower >= tick_upper:
2908
+ tick_upper = tick_lower + tick_spacing
2909
+ return (tick_lower, tick_upper)
2910
+
2911
+
2912
+ def cmd_aero_add_liquidity(pool_key: str, amount0: float = None,
2913
+ amount1: float = None, slippage_pct: float = 1.0,
2914
+ execute: bool = False, width_pct: float = 2.0):
2915
+ """Add concentrated liquidity to an Aerodrome Slipstream pool through the
2916
+ Degen Account's AerodromeFacet. Uses in-account token0/token1 balances.
2917
+
2918
+ --pool selects a whitelisted CL pool (e.g. weth-usdc-100).
2919
+ --amount-weth / --amount-usdc (or --amount-token0 / --amount-token1) specify
2920
+ the desired liquidity amounts in token units. At least one side must be >0.
2921
+ --slippage sets the min-amount floor (default 1%).
2922
+ --width sets the range ±width% around the current price (default 2%).
2923
+
2924
+ The facet wraps the NPM mint(MintParams) call with remainsSolvent, so
2925
+ --execute appends a RedStone signed-price payload."""
2926
+ w3 = get_w3()
2927
+ acct = get_account()
2928
+ print(f"Wallet: {acct.address}")
2929
+ pa = get_prime_account(w3, acct.address)
2930
+ if not pa:
2931
+ print("No Degen Account yet. Create with: degenprime create-account --execute")
2932
+ return
2933
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
2934
+ print(f"Degen Account: {pa}")
2935
+
2936
+ if pool_key not in AERODROME_POOLS:
2937
+ print(f"Unknown pool '{pool_key}'. Choose from: {', '.join(AERODROME_POOLS)}")
2938
+ return
2939
+ pool_cfg = AERODROME_POOLS[pool_key]
2940
+
2941
+ # Convert amounts to wei
2942
+ amt0 = to_wei_units(amount0, pool_cfg["decimals0"]) if amount0 else 0
2943
+ amt1 = to_wei_units(amount1, pool_cfg["decimals1"]) if amount1 else 0
2944
+ if amt0 == 0 and amt1 == 0:
2945
+ print("At least one of --amount-<token0> / --amount-<token1> must be > 0")
2946
+ return
2947
+
2948
+ # Get current price from KuCoin for tick range calculation
2949
+ price_sym = pool_cfg["symbol0"] + "-USDT"
2950
+ centre_price = None
2951
+ try:
2952
+ r = requests.get(f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={price_sym}", timeout=3)
2953
+ if r.status_code == 200 and r.json().get("code") == "200000":
2954
+ centre_price = float(r.json()["data"]["price"])
2955
+ except Exception:
2956
+ pass
2957
+
2958
+ tick_lower, tick_upper = _aero_tick_range(pool_cfg["tickSpacing"], centre_price, width_pct)
2959
+ params = _aero_mint_params(pool_cfg, amt0, amt1, tick_lower, tick_upper,
2960
+ pa, slippage_pct)
2961
+
2962
+ sym0, sym1 = pool_cfg["symbol0"], pool_cfg["symbol1"]
2963
+ print(f"Pool: {sym0}/{sym1} (tickSpacing={pool_cfg['tickSpacing']})")
2964
+ if centre_price:
2965
+ print(f" Current {sym0} price: ${centre_price:,.2f}")
2966
+ print(f" Tick range: [{tick_lower}, {tick_upper}] → price [{1.0001**tick_lower:.4f}, {1.0001**tick_upper:.4f}]")
2967
+ print(f" Width: ±{width_pct}%")
2968
+ else:
2969
+ print(f" Full-range position (no price data available)")
2970
+ print(f" {sym0}: {amount0 or 0} ({amt0} wei) {sym1}: {amount1 or 0} ({amt1} wei)")
2971
+
2972
+ if not execute:
2973
+ print("Preview only. Run with --execute to broadcast.")
2974
+ return
2975
+
2976
+ # Build RedStone payload for solvency check
2977
+ feeds = degen_account_price_feeds(account)
2978
+ payload = build_redstone_payload(feeds)
2979
+
2980
+ # Encode the mint call: use the probed selector + ABI-encoded params
2981
+ mint_data = account.encode_abi("mintAerodrome", args=[params])
2982
+ # encode_abi returns hex string "0x...", extract params bytes after selector
2983
+ mint_params_bytes = bytes.fromhex(mint_data[2:])[4:]
2984
+ mint_calldata = AERODROME_SEL_MINT + mint_params_bytes + payload
2985
+
2986
+ tx = {
2987
+ "from": acct.address,
2988
+ "to": account.address,
2989
+ "nonce": w3.eth.get_transaction_count(acct.address),
2990
+ "gas": 5000000,
2991
+ "chainId": CHAIN_ID,
2992
+ "data": "0x" + mint_calldata.hex(),
2993
+ }
2994
+ _set_gas_price(w3, tx)
2995
+ signed = acct.sign_transaction(tx)
2996
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2997
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=300)
2998
+ ok = receipt["status"] == 1
2999
+ print(f"{'✓' if ok else '✗'} Add liquidity {'confirmed' if ok else 'failed'}")
3000
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3001
+
3002
+
3003
+ def cmd_aero_remove_liquidity(token_id: int, percentage: float = 100.0,
3004
+ execute: bool = False):
3005
+ """Remove liquidity from an Aerodrome Slipstream position owned by the
3006
+ Degen Account. Decreases liquidity by `percentage` of the current position
3007
+ size (default 100% = full close). The position NFT remains; burn it
3008
+ separately after all liquidity is removed and fees collected.
3009
+
3010
+ The facet wraps NPM.decreaseLiquidity. No RedStone payload needed
3011
+ (the decrease path is NOT remainsSolvent — same as TJ lb-remove)."""
3012
+ w3 = get_w3()
3013
+ acct = get_account()
3014
+ print(f"Wallet: {acct.address}")
3015
+ pa = get_prime_account(w3, acct.address)
3016
+ if not pa:
3017
+ print("No Degen Account yet.")
3018
+ return
3019
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
3020
+ print(f"Degen Account: {pa}")
3021
+
3022
+ # Read the position's current liquidity
3023
+ try:
3024
+ pos = account.functions.getPositionCompositionSimplified(token_id).call()
3025
+ except Exception as e:
3026
+ print(f" Cannot read position {token_id}: {e}")
3027
+ return
3028
+ token0, token1, tick_data, current_liq = pos
3029
+ if current_liq == 0:
3030
+ print(f" Position {token_id} has 0 liquidity (may already be closed).")
3031
+ return
3032
+
3033
+ sym0 = _resolve_token_symbol(w3, token0)
3034
+ sym1 = _resolve_token_symbol(w3, token1)
3035
+ tick_lower = _int24_from_hi128(tick_data)
3036
+ tick_upper = _int24_from_lo128(tick_data)
3037
+
3038
+ remove_liq = int(Decimal(str(current_liq)) * Decimal(str(percentage)) / Decimal(100))
3039
+ if remove_liq <= 0:
3040
+ print(f" Removal percentage {percentage}% yields 0 liquidity.")
3041
+ return
3042
+
3043
+ print(f"Position {token_id}: {sym0}/{sym1} ticks=[{tick_lower},{tick_upper}]")
3044
+ print(f" Current liquidity: {current_liq}")
3045
+ print(f" Removing: {remove_liq} ({percentage}%)")
3046
+
3047
+ if not execute:
3048
+ print("Preview only. Run with --execute to broadcast.")
3049
+ return
3050
+
3051
+ params = _aero_decrease_params(token_id, remove_liq, 0, 0)
3052
+ # decreaseLiquidity on the facet (selector ca15558b)
3053
+ dec_data = account.encode_abi("decreaseAerodromeLiquidity", args=[params])
3054
+ dec_params_bytes = bytes.fromhex(dec_data[2:])[4:]
3055
+ dec_calldata = AERODROME_SEL_DECREASE + dec_params_bytes
3056
+
3057
+ tx = {
3058
+ "from": acct.address,
3059
+ "to": account.address,
3060
+ "nonce": w3.eth.get_transaction_count(acct.address),
3061
+ "gas": 4000000,
3062
+ "chainId": CHAIN_ID,
3063
+ "data": "0x" + dec_calldata.hex(),
3064
+ }
3065
+ _set_gas_price(w3, tx)
3066
+ signed = acct.sign_transaction(tx)
3067
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
3068
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=300)
3069
+ ok = receipt["status"] == 1
3070
+ print(f"{'✓' if ok else '✗'} Remove liquidity {'confirmed' if ok else 'failed'}")
3071
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3072
+ if ok and percentage >= 100:
3073
+ print(f" Position fully withdrawn. Collect fees then burn:")
3074
+ print(f" degenprime aero-collect-fees --token-id {token_id} --execute")
3075
+
3076
+
3077
+ def cmd_aero_collect_fees(token_id: int, execute: bool = False):
3078
+ """Collect accrued swap fees from an Aerodrome Slipstream position.
3079
+ Fees accumulate as tokensOwed0/tokensOwed1 on the NPM; collect sends them
3080
+ to the Degen Account. After collecting all fees (and removing all liquidity),
3081
+ the NFT position can be burned.
3082
+
3083
+ Uses the facet's collect/burn path (selector 0x92b5a47e)."""
3084
+ w3 = get_w3()
3085
+ acct = get_account()
3086
+ print(f"Wallet: {acct.address}")
3087
+ pa = get_prime_account(w3, acct.address)
3088
+ if not pa:
3089
+ print("No Degen Account yet.")
3090
+ return
3091
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
3092
+ print(f"Degen Account: {pa}")
3093
+
3094
+ # Read position composition for display
3095
+ try:
3096
+ pos = account.functions.getPositionCompositionSimplified(token_id).call()
3097
+ except Exception as e:
3098
+ print(f" Cannot read position {token_id}: {e}")
3099
+ return
3100
+ token0, token1, tick_data, liq = pos
3101
+ sym0 = _resolve_token_symbol(w3, token0)
3102
+ sym1 = _resolve_token_symbol(w3, token1)
3103
+ print(f"Position {token_id}: {sym0}/{sym1} liquidity={liq}")
3104
+
3105
+ # Also try to read uncollected fees from the NPM directly
3106
+ try:
3107
+ npm = w3.eth.contract(address=Web3.to_checksum_address(AERODROME_NPM),
3108
+ abi=AERODROME_NPM_ABI)
3109
+ npm_pos = npm.functions.positions(token_id).call()
3110
+ owed0 = npm_pos[10] # tokensOwed0
3111
+ owed1 = npm_pos[11] # tokensOwed1
3112
+ if owed0 > 0 or owed1 > 0:
3113
+ print(f" Uncollected fees: {owed0} ({sym0}) + {owed1} ({sym1})")
3114
+ else:
3115
+ print(f" No uncollected fees.")
3116
+ except Exception:
3117
+ print(f" (Cannot fetch uncollected fees from NPM directly)")
3118
+
3119
+ if not execute:
3120
+ print("Preview only. Run with --execute to broadcast.")
3121
+ return
3122
+
3123
+ # Build RedStone payload (collect may be solvency-gated)
3124
+ try:
3125
+ feeds = degen_account_price_feeds(account)
3126
+ payload = build_redstone_payload(feeds)
3127
+ except Exception:
3128
+ payload = b""
3129
+
3130
+ # Encode collect call with the probed selector
3131
+ collect_data = account.encode_abi("collectAerodromeFees", args=[token_id])
3132
+ collect_params_bytes = bytes.fromhex(collect_data[2:])[4:]
3133
+ collect_calldata = AERODROME_SEL_COLLECT + collect_params_bytes
3134
+ if payload:
3135
+ collect_calldata += payload
3136
+
3137
+ tx = {
3138
+ "from": acct.address,
3139
+ "to": account.address,
3140
+ "nonce": w3.eth.get_transaction_count(acct.address),
3141
+ "gas": 3000000,
3142
+ "chainId": CHAIN_ID,
3143
+ "data": "0x" + collect_calldata.hex(),
3144
+ }
3145
+ _set_gas_price(w3, tx)
3146
+ signed = acct.sign_transaction(tx)
3147
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
3148
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=300)
3149
+ ok = receipt["status"] == 1
3150
+ print(f"{'✓' if ok else '✗'} Collect fees {'confirmed' if ok else 'failed'}")
3151
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3152
+
2541
3153
 
2542
3154
  def main():
2543
3155
  check_version()
@@ -2731,6 +3343,45 @@ def _dispatch():
2731
3343
  cmd_execute_pool_withdrawal(pool, index, execute)
2732
3344
  elif cmd == "aerodrome-positions":
2733
3345
  cmd_aerodrome_positions()
3346
+ elif cmd == "aero-add-liquidity":
3347
+ pool_key = None
3348
+ amt0, amt1 = None, None
3349
+ slippage = 1.0
3350
+ width = 2.0
3351
+ execute = "--execute" in args
3352
+ for i, a in enumerate(args):
3353
+ if a == "--pool" and i + 1 < len(args): pool_key = args[i + 1]
3354
+ if a == "--amount-weth" and i + 1 < len(args): amt0 = float(args[i + 1])
3355
+ if a == "--amount-usdc" and i + 1 < len(args): amt1 = float(args[i + 1])
3356
+ if a == "--amount-token0" and i + 1 < len(args): amt0 = float(args[i + 1])
3357
+ if a == "--amount-token1" and i + 1 < len(args): amt1 = float(args[i + 1])
3358
+ if a == "--amount-aero" and i + 1 < len(args): amt0 = float(args[i + 1])
3359
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3360
+ if a == "--width" and i + 1 < len(args): width = float(args[i + 1])
3361
+ if not pool_key or (amt0 is None and amt1 is None):
3362
+ print("Usage: degenprime aero-add-liquidity --pool weth-usdc-100 --amount-weth 0.05 --amount-usdc 100 [--slippage 1] [--width 2] [--execute]")
3363
+ return
3364
+ cmd_aero_add_liquidity(pool_key, amt0, amt1, slippage, execute, width)
3365
+ elif cmd == "aero-remove-liquidity":
3366
+ token_id = None
3367
+ percentage = 100.0
3368
+ execute = "--execute" in args
3369
+ for i, a in enumerate(args):
3370
+ if a == "--token-id" and i + 1 < len(args): token_id = int(args[i + 1])
3371
+ if a == "--percentage" and i + 1 < len(args): percentage = float(args[i + 1])
3372
+ if token_id is None:
3373
+ print("Usage: degenprime aero-remove-liquidity --token-id N [--percentage 100] [--execute]")
3374
+ return
3375
+ cmd_aero_remove_liquidity(token_id, percentage, execute)
3376
+ elif cmd == "aero-collect-fees":
3377
+ token_id = None
3378
+ execute = "--execute" in args
3379
+ for i, a in enumerate(args):
3380
+ if a == "--token-id" and i + 1 < len(args): token_id = int(args[i + 1])
3381
+ if token_id is None:
3382
+ print("Usage: degenprime aero-collect-fees --token-id N [--execute]")
3383
+ return
3384
+ cmd_aero_collect_fees(token_id, execute)
2734
3385
  elif cmd == "health":
2735
3386
  os.environ.setdefault("PRIMECLI_TOOL", sys.argv[0])
2736
3387
  if health_monitor:
@@ -22,6 +22,7 @@ Strategy config (JSON):
22
22
 
23
23
  import json
24
24
  import os
25
+ import re
25
26
  import subprocess
26
27
  import sys
27
28
  import time
@@ -36,18 +37,22 @@ TIER_MAX = {"basic": 5, "premium": 10}
36
37
  # Health computation
37
38
  # ════════════════════════════════════════════════════════════════════
38
39
 
39
- def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
40
- """Compute health (0-100%) using the frontend formula from DeltaPrime docs.
40
+ def compute_health(
41
+ defi_data: dict,
42
+ max_mult: int = 10,
43
+ per_asset_powers: dict[str, int] | None = None,
44
+ ) -> dict:
45
+ """Compute health (0-100%) using the cross-margin formula from DeltaPrime docs.
41
46
 
42
- Uses the cross-margin health formula (all assets assumed same borrowing power):
43
- equity = total_supplied_usd - total_debt_usd
44
- health_pct = 100 * (1 - debt / (max_mult * equity))
45
- (0% = liquidation, 100% = no debt)
47
+ Cross-margin formula (when per_asset_powers is provided):
48
+ Pr_i = power_i / (power_i + 1) # borrowing power ratio per asset
49
+ Cw_i = supplied_usd_i x Pr_i # weighted collateral per asset
50
+ Bw_i = borrowed_usd_i x Pr_i # weighted borrows per asset
51
+ H = (SigmaCw + SigmaBw - B) / SigmaCw x 100
46
52
 
47
- Background:
48
- The frontend uses Pr = tier / (tier + 1) and computes:
49
- health_pct = (Pr * supplied - debt) / (Pr * equity) * 100
50
- which simplifies to: 100 * (1 - debt / (max_mult * equity)).
53
+ Falls back to the simplified uniform formula when per_asset_powers is None
54
+ (all assets assumed at max_mult borrowing power):
55
+ health_pct = 100 * (1 - debt / (max_mult * equity))
51
56
 
52
57
  DIFFERENT from the equity-based "health_pct" in defi --json / prime-summary
53
58
  (which uses max_debt = equity * (tier - 1)).
@@ -60,34 +65,6 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
60
65
  supplied = g.get("supplied", [])
61
66
  borrowed = g.get("borrowed", [])
62
67
  health_ratio = g.get("health_ratio", 0) or 0
63
- # Use precomputed health_pct from defi --json if available (primecli >= 0.5.4)
64
- precomputed = g.get("health_pct")
65
- if precomputed is not None:
66
- # Override health_pct with frontend formula (ignores precomputed value)
67
- supplied_usd = sum(s.get("usd", 0) or 0 for s in supplied)
68
- debt_usd = sum(b.get("usd", 0) or 0 for b in borrowed)
69
- equity = max(supplied_usd - debt_usd, 0.01)
70
- raw_usdc = sum(s.get("usd", 0) for s in supplied if s.get("symbol") == "USDC")
71
- symbols = [s.get("symbol", "") for s in supplied]
72
- has_gmx = sum(s.get("usd", 0) for s in supplied if "GM_" in s.get("symbol", "")) > 1.0
73
- has_lb = any(sym in ("LB_AVAX_USDC", "LB_WAVAX_USDC", "JOE") or "TRADERJOE" in sym.upper() for sym in symbols)
74
- has_aero = any("AERO" in sym.upper() or "CL_POSITION" in sym.upper() for sym in symbols)
75
- # Frontend formula: health_pct = 100 * (1 - debt / (max_mult * equity))
76
- fe_health = max(0.0, 100.0 * (1.0 - round(debt_usd, 2) / (max_mult * equity)))
77
- fe_max_debt = round(max_mult * equity, 2)
78
- return {
79
- "health_pct": round(fe_health, 1),
80
- "health_ratio": round(health_ratio, 4),
81
- "supplied_usd": round(supplied_usd, 2),
82
- "debt_usd": round(debt_usd, 2),
83
- "equity": round(equity, 2),
84
- "max_debt": round(max(0, max_mult * equity), 2),
85
- "raw_usdc": round(raw_usdc, 2),
86
- "has_gmx": has_gmx,
87
- "has_lb": has_lb,
88
- "has_aero": has_aero,
89
- "action": "computed from defi --json health_pct",
90
- }
91
68
  else:
92
69
  supplied = defi_data.get("supplied", [])
93
70
  borrowed = defi_data.get("borrowed", [])
@@ -107,12 +84,48 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
107
84
  "error": "equity near zero",
108
85
  }
109
86
 
110
- max_debt = round(max_mult * equity, 2) # frontend formula: max debt before liquidation
87
+ # ── Cross-margin formula (per-asset borrowing powers) ─────────────
88
+ if per_asset_powers is not None:
89
+ powers: dict[str, int] = per_asset_powers
90
+ sum_cw = 0.0 # SigmaCw
91
+ sum_bw = 0.0 # SigmaBw
92
+ total_debt = 0.0
93
+
94
+ for s in supplied:
95
+ sym = s.get("symbol", "")
96
+ usd_val = s.get("usd", 0) or 0
97
+ p = powers.get(sym, max_mult)
98
+ pr = p / (p + 1)
99
+ sum_cw += usd_val * pr
100
+
101
+ for b in borrowed:
102
+ sym = b.get("symbol", "")
103
+ usd_val = b.get("usd", 0) or 0
104
+ p = powers.get(sym, max_mult)
105
+ pr = p / (p + 1)
106
+ sum_bw += usd_val * pr
107
+ total_debt += usd_val
108
+
109
+ if sum_cw > 0.01:
110
+ # H = (SigmaCw + SigmaBw - B) / SigmaCw * 100
111
+ health_pct = max(0.0, (sum_cw + sum_bw - total_debt) / sum_cw * 100.0)
112
+ else:
113
+ health_pct = 0.0
114
+
115
+ max_debt = round(max_mult * equity, 2)
116
+
117
+ # ── Simplified formula fallback (uniform borrowing power) ─────────
118
+ else:
119
+ max_debt = round(max_mult * equity, 2)
120
+
121
+ if max_debt > 0.01 and debt_usd >= 0:
122
+ health_pct = max(0.0, 100.0 * (1.0 - round(debt_usd, 2) / max_debt))
123
+ else:
124
+ health_pct = 100.0
111
125
 
112
- # Raw USDC in account
126
+ # Common features regardless of formula variant
113
127
  raw_usdc = sum(s.get("usd", 0) for s in supplied if s.get("symbol") == "USDC")
114
128
 
115
- # Position type detection
116
129
  symbols = [s.get("symbol", "") for s in supplied]
117
130
  has_gmx = sum(s.get("usd", 0) for s in supplied if "GM_" in s.get("symbol", "")) > 1.0
118
131
  has_lb = any(
@@ -122,11 +135,6 @@ def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
122
135
  )
123
136
  has_aero = any("AERO" in sym.upper() or "CL_POSITION" in sym.upper() for sym in symbols)
124
137
 
125
- if max_debt > 0.01 and debt_usd >= 0:
126
- health_pct = max(0.0, 100.0 * (1.0 - round(debt_usd, 2) / max_debt))
127
- else:
128
- health_pct = 100.0
129
-
130
138
  # Center target (50% health): target_debt = max_debt * 0.5
131
139
  delta_debt = (max_debt * 0.5) - debt_usd
132
140
 
@@ -560,12 +568,52 @@ def run_tick(
560
568
  deployed_fail += 1
561
569
 
562
570
  elif pos_type == "lb":
563
- # LB deposits need pair + amount-x + amount-y (not a single amount),
564
- # so just leave as USDC for now — manual deployment required.
565
- result["action"] = f"lb-add needs pair + dual amounts — leaving ${split_amt:.2f} as USDC"
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]
591
+ try:
592
+ 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"],
600
+ capture_output=True, text=True, timeout=120,
601
+ )
602
+ if r.returncode == 0:
603
+ deployed_ok += 1
604
+ else:
605
+ result["warning"] = f"lb-add failed: {r.stderr[:200]}"
606
+ deployed_fail += 1
607
+ except Exception as e:
608
+ result["error"] = f"lb-add error: {e}"
609
+ deployed_fail += 1
566
610
 
567
611
  elif pos_type == "aero":
568
- result["action"] = f"aero deposit not yet supported by tool — leaving ${split_amt:.2f} as USDC"
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"
569
617
 
570
618
  if deployed_ok > 0:
571
619
  cooldown_file.write_text(str(int(time.time())))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.5.8
3
+ Version: 0.6.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "primecli"
7
- version = "0.5.8"
7
+ version = "0.6.0"
8
8
  description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes