primecli 0.6.0__tar.gz → 0.7.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 (23) hide show
  1. {primecli-0.6.0 → primecli-0.7.0}/PKG-INFO +2 -2
  2. {primecli-0.6.0 → primecli-0.7.0}/README.md +1 -1
  3. {primecli-0.6.0 → primecli-0.7.0}/primecli/arbprime.py +255 -38
  4. {primecli-0.6.0 → primecli-0.7.0}/primecli/degenprime.py +251 -41
  5. {primecli-0.6.0 → primecli-0.7.0}/primecli/deltaprime.py +254 -37
  6. {primecli-0.6.0 → primecli-0.7.0}/primecli.egg-info/PKG-INFO +2 -2
  7. {primecli-0.6.0 → primecli-0.7.0}/primecli.egg-info/SOURCES.txt +1 -0
  8. {primecli-0.6.0 → primecli-0.7.0}/pyproject.toml +1 -1
  9. {primecli-0.6.0 → primecli-0.7.0}/tests/test_cross_file_identity.py +5 -0
  10. primecli-0.7.0/tests/test_health_meter.py +340 -0
  11. {primecli-0.6.0 → primecli-0.7.0}/tests/test_health_monitor.py +13 -10
  12. {primecli-0.6.0 → primecli-0.7.0}/LICENSE +0 -0
  13. {primecli-0.6.0 → primecli-0.7.0}/primecli/__init__.py +0 -0
  14. {primecli-0.6.0 → primecli-0.7.0}/primecli/health_monitor.py +0 -0
  15. {primecli-0.6.0 → primecli-0.7.0}/primecli.egg-info/dependency_links.txt +0 -0
  16. {primecli-0.6.0 → primecli-0.7.0}/primecli.egg-info/entry_points.txt +0 -0
  17. {primecli-0.6.0 → primecli-0.7.0}/primecli.egg-info/requires.txt +0 -0
  18. {primecli-0.6.0 → primecli-0.7.0}/primecli.egg-info/top_level.txt +0 -0
  19. {primecli-0.6.0 → primecli-0.7.0}/setup.cfg +0 -0
  20. {primecli-0.6.0 → primecli-0.7.0}/tests/test_gas_pricing.py +0 -0
  21. {primecli-0.6.0 → primecli-0.7.0}/tests/test_paraswap_validator.py +0 -0
  22. {primecli-0.6.0 → primecli-0.7.0}/tests/test_redstone_encoding.py +0 -0
  23. {primecli-0.6.0 → primecli-0.7.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.6.0
3
+ Version: 0.7.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.5.6 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
50
+ **Current version:** 0.7.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.5.6 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
19
+ **Current version:** 0.7.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
 
@@ -503,7 +503,7 @@ _T_WEETH = {"addr": "0x35751007a407ca6FEFfE80b3cB397736D2cf4dbe", "symbol": "we
503
503
  TJ_LB_PAIRS = {
504
504
  "eth-usdc": {"pair": "0x69f1216cB2905bf0852f74624D5Fa7b5FC4dA710", "router": TJ_ROUTER_V21, "binStep": 15, "tokenX": _T_WETH, "tokenY": _T_USDC},
505
505
  "eth-usdc-10": {"pair": "0xb7236B927e03542AC3bE0A054F2bEa8868AF9508", "router": TJ_ROUTER_V22, "binStep": 10, "tokenX": _T_WETH, "tokenY": _T_USDC},
506
- "eth-usdt": {"pair": "0xd387c40a72703B38A5181573724bcaF2Ce6038a5", "router": TJ_ROUTER_V21, "binStep": 15, "tokenX": _T_WETH, "tokenY": _T_USDC},
506
+ "eth-usdt": {"pair": "0xd387c40a72703B38A5181573724bcaF2Ce6038a5", "router": TJ_ROUTER_V21, "binStep": 15, "tokenX": _T_WETH, "tokenY": _T_USDT},
507
507
  }
508
508
 
509
509
  # LB pair (ILBPair) reads used for previews + position views. getActiveId is the current
@@ -1921,8 +1921,21 @@ def gather_lending(w3, account):
1921
1921
  sym = n.rstrip(b"\x00").decode(errors="replace")
1922
1922
  if sym and sym not in feeds:
1923
1923
  feeds.append(sym)
1924
- out = {"supplied": supplied, "borrowed": borrowed,
1924
+ out = {"supplied": supplied, "borrowed": borrowed, "w3": w3,
1925
1925
  "total_value_usd": None, "debt_usd": None, "health_ratio": None, "solvent": None}
1926
+ # Resolve the account's PRIME tier (oracle-free) and per-asset on-chain debtCoverage,
1927
+ # then stamp dc onto every row so _compute_health_pct can run getHealthMeter exactly.
1928
+ try:
1929
+ tier_code = account.functions.getLeverageTierFullInfo().call()[0]
1930
+ except Exception:
1931
+ tier_code = 0
1932
+ out["tier_code"] = tier_code
1933
+ try:
1934
+ dc_map = _resolve_debt_coverages(w3, [r["symbol"] for r in supplied + borrowed], tier_code)
1935
+ for r in supplied + borrowed:
1936
+ r["dc"] = dc_map.get(r["symbol"], 0.0)
1937
+ except Exception:
1938
+ pass
1926
1939
  try:
1927
1940
  payload = build_redstone_payload(feeds)
1928
1941
  payload_hex = payload.hex()
@@ -1983,46 +1996,188 @@ def gather_lending(w3, account):
1983
1996
  r["usd"] = None
1984
1997
  return out
1985
1998
 
1999
+ def _health_meter_pct(assets: list) -> dict:
2000
+ """getHealthMeter() exactly as the on-chain HealthMeterFacetProd renders it.
2001
+
2002
+ The frontend health meter is NOT equity*(mult-1); it is a per-asset, debtCoverage-
2003
+ weighted formula. For each asset i with USD-valued long balance and borrow, and its
2004
+ live debtCoverage dc_i:
2005
+
2006
+ net_i = supplied_usd_i - borrowed_usd_i
2007
+ weightedCollateralPlus = Σ dc_i·net_i for net_i > 0 (net-long legs)
2008
+ weightedCollateralMinus = Σ dc_i·(-net_i) for net_i < 0 (net-short legs)
2009
+ weightedCollateral = weightedCollateralPlus - weightedCollateralMinus
2010
+ weightedBorrowed = Σ dc_i·borrowed_usd_i
2011
+ borrowed = Σ borrowed_usd_i (UNWEIGHTED)
2012
+
2013
+ borrowed == 0 -> 100
2014
+ weightedCollateral > 0 and
2015
+ weightedCollateral + weightedBorrowed > borrowed
2016
+ -> (weightedCollateral + weightedBorrowed - borrowed) / weightedCollateral · 100
2017
+ else -> 0
2018
+
2019
+ Result clamped to [0, 100]. `assets` is a list of
2020
+ {"symbol", "dc", "supplied_usd", "borrowed_usd"}; missing usd legs count as 0.
2021
+ For a uniform-dc single-collateral position this reduces to the familiar
2022
+ (max_debt - debt)/max_debt·100 with max_debt = equity·dc/(1-dc).
2023
+ """
2024
+ wc_plus = 0.0
2025
+ wc_minus = 0.0
2026
+ weighted_borrowed = 0.0
2027
+ borrowed = 0.0
2028
+ supplied_usd = 0.0
2029
+ debt_usd = 0.0
2030
+ for a in assets:
2031
+ dc = a.get("dc", 0.0) or 0.0
2032
+ sup = a.get("supplied_usd", 0.0) or 0.0
2033
+ bor = a.get("borrowed_usd", 0.0) or 0.0
2034
+ supplied_usd += sup
2035
+ debt_usd += bor
2036
+ net = sup - bor
2037
+ if net > 0:
2038
+ wc_plus += dc * net
2039
+ elif net < 0:
2040
+ wc_minus += dc * (-net)
2041
+ weighted_borrowed += dc * bor
2042
+ borrowed += bor
2043
+ weighted_collateral = wc_plus - wc_minus
2044
+ equity = supplied_usd - debt_usd
2045
+ if borrowed <= 0:
2046
+ health_pct = 100.0
2047
+ elif weighted_collateral > 0 and (weighted_collateral + weighted_borrowed) > borrowed:
2048
+ health_pct = (weighted_collateral + weighted_borrowed - borrowed) / weighted_collateral * 100.0
2049
+ health_pct = max(0.0, min(100.0, health_pct))
2050
+ else:
2051
+ health_pct = 0.0
2052
+ return {"health_pct": round(health_pct, 1), "supplied_usd": round(supplied_usd, 2),
2053
+ "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2054
+ "weighted_collateral": round(weighted_collateral, 2),
2055
+ "weighted_borrowed": round(weighted_borrowed, 2)}
2056
+
2057
+
2058
+ _dc_cache = {}
2059
+
2060
+
2061
+ def _resolve_debt_coverages(w3, symbols: list, tier_code: int = 0) -> dict:
2062
+ """Per-asset debtCoverage read LIVE on-chain from the TokenManager, keyed by symbol.
2063
+
2064
+ Resolves each symbol to its token address via getAssetAddress(bytes32,true), then reads
2065
+ the account's effective coverage: tieredDebtCoverage(tier, token) on Avalanche/Arbitrum
2066
+ (the contract's getHealthMeter uses getPrimeLeverageTier() for exactly this), falling
2067
+ back to the un-tiered debtCoverage(token). Cached per run keyed by (symbol, tier_code).
2068
+ Batched through multicall so N assets cost ~2 eth_calls, not 2N. Symbols that don't
2069
+ resolve get dc=0 (they contribute nothing — same as the contract skipping an unpriced
2070
+ leg)."""
2071
+ want = [s for s in dict.fromkeys(symbols) if s]
2072
+ out = {}
2073
+ missing = []
2074
+ for s in want:
2075
+ ck = (s, tier_code)
2076
+ if ck in _dc_cache:
2077
+ out[s] = _dc_cache[ck]
2078
+ else:
2079
+ missing.append(s)
2080
+ if not missing:
2081
+ return out
2082
+ tm_abi = json.loads(
2083
+ '[{"inputs":[{"name":"_asset","type":"bytes32"},{"name":"_active","type":"bool"}],'
2084
+ '"name":"getAssetAddress","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"},'
2085
+ '{"inputs":[{"name":"a","type":"address"}],"name":"debtCoverage","outputs":[{"type":"uint256"}],'
2086
+ '"stateMutability":"view","type":"function"},'
2087
+ '{"inputs":[{"name":"t","type":"uint8"},{"name":"a","type":"address"}],"name":"tieredDebtCoverage",'
2088
+ '"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2089
+ tm = w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=tm_abi)
2090
+ addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(s), True])[2:]))
2091
+ for s in missing]
2092
+ addr_res = multicall(w3, addr_legs)
2093
+ addrs = {}
2094
+ for s, (ok, rd) in zip(missing, addr_res):
2095
+ try:
2096
+ a = w3.codec.decode(["address"], rd)[0] if ok and rd else None
2097
+ except Exception:
2098
+ a = None
2099
+ addrs[s] = a if a and int(a, 16) != 0 else None
2100
+ resolvable = [s for s in missing if addrs[s]]
2101
+ # Try tiered coverage first (Avalanche/Arbitrum); fall back per-asset to un-tiered.
2102
+ tiered_legs = [(TOKEN_MANAGER, bytes.fromhex(
2103
+ tm.encode_abi("tieredDebtCoverage", args=[tier_code, Web3.to_checksum_address(addrs[s])])[2:]))
2104
+ for s in resolvable]
2105
+ untiered_legs = [(TOKEN_MANAGER, bytes.fromhex(
2106
+ tm.encode_abi("debtCoverage", args=[Web3.to_checksum_address(addrs[s])])[2:]))
2107
+ for s in resolvable]
2108
+ tiered_res = multicall(w3, tiered_legs) if tiered_legs else []
2109
+ untiered_res = multicall(w3, untiered_legs) if untiered_legs else []
2110
+ for i, s in enumerate(resolvable):
2111
+ dc = 0.0
2112
+ ok_t, rd_t = tiered_res[i]
2113
+ if ok_t and rd_t:
2114
+ try:
2115
+ dc = w3.codec.decode(["uint256"], rd_t)[0] / 1e18
2116
+ except Exception:
2117
+ dc = 0.0
2118
+ if dc <= 0:
2119
+ ok_u, rd_u = untiered_res[i]
2120
+ if ok_u and rd_u:
2121
+ try:
2122
+ dc = w3.codec.decode(["uint256"], rd_u)[0] / 1e18
2123
+ except Exception:
2124
+ dc = 0.0
2125
+ _dc_cache[(s, tier_code)] = dc
2126
+ out[s] = dc
2127
+ for s in missing:
2128
+ if s not in out:
2129
+ _dc_cache[(s, tier_code)] = 0.0
2130
+ out[s] = 0.0
2131
+ return out
2132
+
2133
+
1986
2134
  def _compute_health_pct(data: dict, tier_code: int = 0) -> dict:
1987
- """Compute equity-based health (0-100%) from gather_lending data + tier.
2135
+ """Frontend-exact health (0-100%) for a Prime Account — wraps _health_meter_pct with
2136
+ on-chain debtCoverage resolution and the tier label.
1988
2137
 
1989
2138
  DeltaPrime has *two* health metrics that agents must not confuse:
1990
2139
 
1991
2140
  1. health_ratio (on-chain, getHealthRatio): 1.0 = liquidation, >1.0 = solvent.
1992
- This is the raw weighted-collateral / debt ratio from the SolvencyFacet.
1993
-
1994
- 2. health_pct (equity-based, 0-100%): the scale used in the DeltaPrime frontend
1995
- and the account-health-monitor cron. 0% = liquidation, 100% = no debt.
1996
- Formula:
1997
- equity = supplied_usd - debt_usd
1998
- max_mult = 10 if PREMIUM tier else 5 if BASIC
1999
- max_debt = equity * (max_mult - 1)
2000
- health_pct = (max_debt - debt_usd) / max_debt * 100
2001
-
2002
- Returns dict with keys: health_pct, supplied_usd, debt_usd, equity, max_debt,
2003
- tier_label, or error.
2141
+ The raw weighted-collateral / debt ratio from the SolvencyFacet.
2142
+
2143
+ 2. health_pct (0-100%, getHealthMeter): the scale the DeltaPrime frontend renders
2144
+ and the account-health-monitor cron acts on. 0% = liquidation, 100% = no debt.
2145
+ Computed by _health_meter_pct with per-asset dc from tieredDebtCoverage at the
2146
+ account's PRIME tier — NOT the old equity*(mult-1) approximation.
2147
+
2148
+ Per-asset USD comes from gather_lending (rows carry `dc` once resolved); if a row has
2149
+ no `dc` key yet it is resolved here from `data["w3"]` when present. Returns
2150
+ health_pct, supplied_usd, debt_usd, equity, max_debt (display zero-crossing debt),
2151
+ tier, or error.
2004
2152
  """
2005
- supplied_usd = sum(r.get("usd", 0) or 0 for r in data.get("supplied", []))
2006
- debt_usd = sum(r.get("usd", 0) or 0 for r in data.get("borrowed", []))
2007
- equity = supplied_usd - debt_usd
2153
+ supplied = data.get("supplied", [])
2154
+ borrowed = data.get("borrowed", [])
2008
2155
  tier_labels = {0: "BASIC", 1: "PREMIUM", 2: "_NON_EXISTENT"}
2009
2156
  tier_label = tier_labels.get(tier_code, str(tier_code))
2010
- max_mult = {0: 5, 1: 10}.get(tier_code, 5)
2011
-
2157
+ # Per-symbol long/short USD, merging an asset that is both supplied and borrowed.
2158
+ syms = list(dict.fromkeys([r["symbol"] for r in supplied + borrowed if r.get("symbol")]))
2159
+ dc_map = {r["symbol"]: r["dc"] for r in supplied + borrowed if r.get("dc") is not None}
2160
+ need = [s for s in syms if s not in dc_map]
2161
+ if need and data.get("w3") is not None:
2162
+ dc_map.update(_resolve_debt_coverages(data["w3"], need, tier_code))
2163
+ assets = []
2164
+ for s in syms:
2165
+ sup = sum(r.get("usd", 0) or 0 for r in supplied if r.get("symbol") == s)
2166
+ bor = sum(r.get("usd", 0) or 0 for r in borrowed if r.get("symbol") == s)
2167
+ assets.append({"symbol": s, "dc": dc_map.get(s, 0.0), "supplied_usd": sup, "borrowed_usd": bor})
2168
+ res = _health_meter_pct(assets)
2169
+ equity = res["equity"]
2012
2170
  if equity <= 0.01:
2013
- return {"health_pct": 0.0, "supplied_usd": round(supplied_usd, 2),
2014
- "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2171
+ return {"health_pct": 0.0, "supplied_usd": res["supplied_usd"],
2172
+ "debt_usd": res["debt_usd"], "equity": equity,
2015
2173
  "max_debt": 0.0, "tier": tier_label, "error": "equity near zero"}
2016
-
2017
- max_debt = equity * (max_mult - 1)
2018
- if max_debt > 0 and debt_usd >= 0:
2019
- health_pct = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
2020
- health_pct = max(0.0, min(100.0, health_pct))
2021
- else:
2022
- health_pct = 100.0
2023
-
2024
- return {"health_pct": round(health_pct, 1), "supplied_usd": round(supplied_usd, 2),
2025
- "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2174
+ # Display-only zero-crossing debt: the unweighted borrow at which health hits 0,
2175
+ # equity·dc_eff/(1-dc_eff) for the position's value-weighted collateral dc.
2176
+ coll_usd = sum(a["supplied_usd"] for a in assets) or 0.0
2177
+ dc_eff = (sum(a["dc"] * a["supplied_usd"] for a in assets) / coll_usd) if coll_usd > 0 else 0.0
2178
+ max_debt = equity * dc_eff / (1.0 - dc_eff) if 0 < dc_eff < 1 else 0.0
2179
+ return {"health_pct": res["health_pct"], "supplied_usd": res["supplied_usd"],
2180
+ "debt_usd": res["debt_usd"], "equity": equity,
2026
2181
  "max_debt": round(max_debt, 2), "tier": tier_label}
2027
2182
 
2028
2183
 
@@ -2148,21 +2303,32 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2148
2303
  # The facet's repay reverts if amount > debt OR amount > in-account balance.
2149
2304
  # Cap to min(requested, debt, in_account) so callers don't need to know either
2150
2305
  # exact figure — pass an overshoot like 9999 and it clips cleanly.
2306
+ # ALSO: the contract's _getAvailableBalance() subtracts pending withdrawal intents,
2307
+ # so we subtract total_intent from the raw balance to get the true cap.
2151
2308
  requested_wei = to_wei_units(amount, cfg["decimals"])
2152
2309
  debt_wei = pool.functions.getBorrowed(pa_cs).call()
2153
2310
  in_acct_wei = account.functions.getBalance(asset_b32(symbol)).call()
2311
+ try:
2312
+ total_intent_wei = account.functions.getTotalIntentAmount(asset_b32(symbol)).call()
2313
+ except Exception:
2314
+ total_intent_wei = 0
2315
+ available_wei = in_acct_wei - total_intent_wei if in_acct_wei > total_intent_wei else 0
2154
2316
  if debt_wei == 0:
2155
2317
  print(f"No {symbol} debt to repay on Prime Account {pa}.")
2156
2318
  return
2157
- amount_wei = min(requested_wei, debt_wei, in_acct_wei)
2319
+ amount_wei = min(requested_wei, debt_wei, available_wei)
2158
2320
  if amount_wei == 0:
2159
- print(f"Repay {amount} {symbol}: in-account {symbol} balance is 0 "
2160
- f"swap into {symbol} first (e.g. arbprime swap --to {symbol} --amount N --execute).")
2321
+ print(f"Repay {amount} {symbol}: available {symbol} balance is 0 "
2322
+ f"(total {in_acct_wei / 10**cfg['decimals']:.6f} minus "
2323
+ f"{total_intent_wei / 10**cfg['decimals']:.6f} pending withdrawal intent) — "
2324
+ f"swap into {symbol} first or wait for intents to mature.")
2161
2325
  return
2162
2326
  cap_notes = []
2163
2327
  if amount_wei < requested_wei:
2164
- if in_acct_wei < min(requested_wei, debt_wei):
2165
- cap_notes.append(f"in-account {symbol} only {in_acct_wei / 10**cfg['decimals']:.6f}")
2328
+ if available_wei < min(requested_wei, debt_wei):
2329
+ cap_notes.append(f"available {symbol} only {available_wei / 10**cfg['decimals']:.6f} "
2330
+ f"(total {in_acct_wei / 10**cfg['decimals']:.6f} minus "
2331
+ f"{total_intent_wei / 10**cfg['decimals']:.6f} pending intent)")
2166
2332
  if debt_wei < requested_wei:
2167
2333
  cap_notes.append(f"debt only {debt_wei / 10**cfg['decimals']:.6f} {symbol}")
2168
2334
 
@@ -2172,11 +2338,15 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2172
2338
  print(f" Capped from requested {amount}: {'; '.join(cap_notes)}")
2173
2339
  print(f" Calls repay(bytes32 '{symbol}', {amount_wei}) on the Prime Account")
2174
2340
  print(f" Current debt: {debt_wei / 10**cfg['decimals']:.6f} {symbol} | "
2175
- f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol}")
2341
+ f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol} | "
2342
+ f"available: {available_wei / 10**cfg['decimals']:.6f} {symbol}")
2176
2343
  if in_acct_wei < debt_wei:
2177
2344
  shortfall = (debt_wei - in_acct_wei) / 10**cfg['decimals']
2178
2345
  print(f" Note: in-account < debt by {shortfall:.6f} {symbol} — "
2179
2346
  f"swap into {symbol} first to close the position fully.")
2347
+ if total_intent_wei > 0:
2348
+ print(f" Note: {total_intent_wei / 10**cfg['decimals']:.6f} {symbol} is locked in pending "
2349
+ f"withdrawal intent(s) and not available for repay.")
2180
2350
  print("Run with --execute to broadcast")
2181
2351
  return
2182
2352
 
@@ -2203,6 +2373,53 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2203
2373
  repaid = amount_wei / 10**cfg['decimals']
2204
2374
  print(f"{'✓' if ok else '✗'} Repay {repaid:.6f} {symbol} {'confirmed' if ok else 'failed'}")
2205
2375
  print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2376
+ if not ok:
2377
+ _print_revert_reason(w3, tx, receipt)
2378
+
2379
+ def _print_revert_reason(w3, tx, receipt):
2380
+ """Try to decode and print the revert reason from a failed tx."""
2381
+ try:
2382
+ result = w3.eth.call({
2383
+ "from": tx["from"], "to": tx["to"], "data": tx["input"],
2384
+ "gas": receipt["gasUsed"],
2385
+ "maxFeePerGas": tx.get("maxFeePerGas", tx.get("gasPrice", 0)),
2386
+ "maxPriorityFeePerGas": tx.get("maxPriorityFeePerGas", 0),
2387
+ }, receipt["blockNumber"])
2388
+ except Exception as e:
2389
+ err = str(e)
2390
+ data = err
2391
+ if isinstance(e.args, (list, tuple)):
2392
+ for arg in e.args:
2393
+ if isinstance(arg, str) and arg.startswith("0x"):
2394
+ data = arg
2395
+ break
2396
+ elif isinstance(arg, dict) and "data" in arg:
2397
+ data = arg["data"]
2398
+ break
2399
+ if isinstance(data, str) and data.startswith("0x") and len(data) >= 10:
2400
+ sel = data[:10]
2401
+ _known_errors = {
2402
+ "0x567fe27a": "Unknown error from Prime Account facet",
2403
+ "0xf4d678b8": "Execution rejected (may be intent-locked balance check)",
2404
+ "0x441a702e": "InsufficientBalance()",
2405
+ "0xfd36fde3": "SignerNotAuthorised (RedStone signer mismatch)",
2406
+ "0x92ba160c": "RedstoneConsensus()",
2407
+ "0xc2c286b7": "SignerNotAuthorised(address)",
2408
+ "0x08c379a0": "require() revert: see message below",
2409
+ }
2410
+ label = _known_errors.get(sel, f"Unknown custom error 0x{sel}")
2411
+ print(f" Revert: {label}")
2412
+ if sel == "0x08c379a0" and len(data) >= 138:
2413
+ try:
2414
+ from eth_abi import decode
2415
+ msg_bytes = bytes.fromhex(data[10:])
2416
+ decoded = decode(["string"], msg_bytes)
2417
+ print(f' Require message: "{decoded[0]}"')
2418
+ except Exception:
2419
+ pass
2420
+ else:
2421
+ print(f" Revert reason: {err[:200]}")
2422
+
2206
2423
 
2207
2424
  def _decode_formatted_offer(raw: bytes):
2208
2425
  """Manually decode YieldYak's FormattedOffer struct