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.
- {primecli-0.7.4 → primecli-0.8.0}/PKG-INFO +2 -2
- {primecli-0.7.4 → primecli-0.8.0}/README.md +1 -1
- {primecli-0.7.4 → primecli-0.8.0}/primecli/arbprime.py +26 -1
- {primecli-0.7.4 → primecli-0.8.0}/primecli/degenprime.py +275 -107
- {primecli-0.7.4 → primecli-0.8.0}/primecli/deltaprime.py +26 -1
- {primecli-0.7.4 → primecli-0.8.0}/primecli/health_monitor.py +234 -101
- {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.7.4 → primecli-0.8.0}/pyproject.toml +1 -1
- {primecli-0.7.4 → primecli-0.8.0}/LICENSE +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/primecli/__init__.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/SOURCES.txt +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/setup.cfg +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_gas_limit.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_gas_pricing.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_health_meter.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_health_monitor.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.7.4 → primecli-0.8.0}/tests/test_redstone_encoding.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
80
|
-
getOwnedStakedAerodromeTokenIds
|
|
79
|
+
Degen Account owns/stakes, showing token0/token1/tick range/liquidity. Enumerates tokenIds
|
|
80
|
+
via getOwnedStakedAerodromeTokenIds, then reads each from the Aerodrome NPM.positions()
|
|
81
|
+
view (correct for staked NFTs, which the simplified facet view reports as 0 liquidity).
|
|
81
82
|
|
|
82
83
|
aero-add-liquidity / aero-remove-liquidity / aero-collect-fees provide write paths to
|
|
83
84
|
the Aerodrome Slipstream NonfungiblePositionManager through the Degen Account's
|
|
@@ -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("
|
|
216
|
+
AERODROME_SEL_MINT = bytes.fromhex("f32f1e56") # mintAndStakeLiquidityAerodrome
|
|
216
217
|
AERODROME_SEL_INCREASE = bytes.fromhex("2c710777") # inferred: increaseLiquidity
|
|
217
|
-
AERODROME_SEL_DECREASE = bytes.fromhex("
|
|
218
|
-
AERODROME_SEL_BURN = bytes.fromhex("
|
|
219
|
-
AERODROME_SEL_COLLECT = bytes.fromhex("
|
|
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"}
|
|
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']
|
|
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']
|
|
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']
|
|
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']
|
|
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
|
-
|
|
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
|
|
3069
|
-
getOwnedStakedAerodromeTokenIds
|
|
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
|
-
#
|
|
3090
|
-
#
|
|
3091
|
-
#
|
|
3092
|
-
#
|
|
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
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
results = multicall(w3, pos_legs)
|
|
3099
|
-
except Exception:
|
|
3100
|
-
results = [(False, b"")] * len(ids)
|
|
3101
|
-
|
|
3102
|
-
for tid, (ok, rd) in zip(ids, results):
|
|
3103
|
-
if not ok or not rd:
|
|
3104
|
-
print(f" [{tid}] composition unavailable")
|
|
3129
|
+
pos = _aero_read_position(w3, tid)
|
|
3130
|
+
if pos is None:
|
|
3131
|
+
print(f" [{tid}] position read failed")
|
|
3105
3132
|
continue
|
|
3106
|
-
|
|
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:
|
|
3166
|
-
def
|
|
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
|
-
|
|
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(
|
|
3174
|
-
amount1_min = int(Decimal(str(
|
|
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
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
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
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
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
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
half_width_ticks = int(centre_tick * width_pct / 100.0)
|
|
3215
|
-
tick_lower = ((centre_tick - half_width_ticks) // tick_spacing) * tick_spacing
|
|
3216
|
-
tick_upper = ((centre_tick + half_width_ticks) // tick_spacing) * tick_spacing
|
|
3217
|
-
# Clamp to valid range
|
|
3218
|
-
MIN_TICK, MAX_TICK = -887272, 887272
|
|
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
|
-
#
|
|
3263
|
-
|
|
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
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}: {
|
|
3428
|
+
print(f" {sym0}: {fmt_token_amount(amt0, pool_cfg['decimals0'])} "
|
|
3429
|
+
f"{sym1}: {fmt_token_amount(amt1, pool_cfg['decimals1'])}")
|
|
3285
3430
|
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
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
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
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
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
#
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
#
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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, "
|
|
594
|
-
"--
|
|
595
|
-
"--
|
|
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"
|
|
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"
|
|
699
|
+
result["error"] = f"gmx deposit error: {e}"
|
|
609
700
|
deployed_fail += 1
|
|
610
701
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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.
|
|
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.
|
|
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
|
+
version = "0.8.0"
|
|
8
8
|
description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|