primecli 0.6.0__tar.gz → 0.6.1__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.6.0 → primecli-0.6.1}/PKG-INFO +1 -1
  2. {primecli-0.6.0 → primecli-0.6.1}/primecli/arbprime.py +254 -37
  3. {primecli-0.6.0 → primecli-0.6.1}/primecli/degenprime.py +230 -34
  4. {primecli-0.6.0 → primecli-0.6.1}/primecli/deltaprime.py +254 -37
  5. {primecli-0.6.0 → primecli-0.6.1}/primecli.egg-info/PKG-INFO +1 -1
  6. {primecli-0.6.0 → primecli-0.6.1}/pyproject.toml +1 -1
  7. {primecli-0.6.0 → primecli-0.6.1}/LICENSE +0 -0
  8. {primecli-0.6.0 → primecli-0.6.1}/README.md +0 -0
  9. {primecli-0.6.0 → primecli-0.6.1}/primecli/__init__.py +0 -0
  10. {primecli-0.6.0 → primecli-0.6.1}/primecli/health_monitor.py +0 -0
  11. {primecli-0.6.0 → primecli-0.6.1}/primecli.egg-info/SOURCES.txt +0 -0
  12. {primecli-0.6.0 → primecli-0.6.1}/primecli.egg-info/dependency_links.txt +0 -0
  13. {primecli-0.6.0 → primecli-0.6.1}/primecli.egg-info/entry_points.txt +0 -0
  14. {primecli-0.6.0 → primecli-0.6.1}/primecli.egg-info/requires.txt +0 -0
  15. {primecli-0.6.0 → primecli-0.6.1}/primecli.egg-info/top_level.txt +0 -0
  16. {primecli-0.6.0 → primecli-0.6.1}/setup.cfg +0 -0
  17. {primecli-0.6.0 → primecli-0.6.1}/tests/test_cross_file_identity.py +0 -0
  18. {primecli-0.6.0 → primecli-0.6.1}/tests/test_gas_pricing.py +0 -0
  19. {primecli-0.6.0 → primecli-0.6.1}/tests/test_health_monitor.py +0 -0
  20. {primecli-0.6.0 → primecli-0.6.1}/tests/test_paraswap_validator.py +0 -0
  21. {primecli-0.6.0 → primecli-0.6.1}/tests/test_redstone_encoding.py +0 -0
  22. {primecli-0.6.0 → primecli-0.6.1}/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.6.1
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
@@ -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
@@ -833,39 +833,175 @@ def _asset_meta(w3, symbol: str):
833
833
  # gateway once instead of per-feed-symbol. Cleared implicitly when the process exits.
834
834
  _redstone_gateway_cache = None
835
835
 
836
- def _compute_health_pct(supplied: list, borrowed: list, max_mult: int = None) -> dict:
837
- """Compute equity-based health (0-100%) from per-asset supplied/borrowed data.
838
-
839
- 0% = liquidation, 50% = half borrowing power used, 100% = no debt.
840
- Formula:
841
- equity = supplied_usd - debt_usd
842
- max_mult = 5 (fixed for DegenPrime; no tier system)
843
- max_debt = equity * (max_mult - 1)
844
- health_pct = (max_debt - debt_usd) / max_debt * 100
845
-
846
- Returns dict with keys: health_pct, supplied_usd, debt_usd, equity, max_debt, or error.
836
+ def _health_meter_pct(assets: list) -> dict:
837
+ """getHealthMeter() exactly as the on-chain HealthMeterFacetProd renders it.
838
+
839
+ The frontend health meter is NOT equity*(mult-1); it is a per-asset, debtCoverage-
840
+ weighted formula. For each asset i with USD-valued long balance and borrow, and its
841
+ live debtCoverage dc_i:
842
+
843
+ net_i = supplied_usd_i - borrowed_usd_i
844
+ weightedCollateralPlus = Σ dc_i·net_i for net_i > 0 (net-long legs)
845
+ weightedCollateralMinus = Σ dc_i·(-net_i) for net_i < 0 (net-short legs)
846
+ weightedCollateral = weightedCollateralPlus - weightedCollateralMinus
847
+ weightedBorrowed = Σ dc_i·borrowed_usd_i
848
+ borrowed = Σ borrowed_usd_i (UNWEIGHTED)
849
+
850
+ borrowed == 0 -> 100
851
+ weightedCollateral > 0 and
852
+ weightedCollateral + weightedBorrowed > borrowed
853
+ -> (weightedCollateral + weightedBorrowed - borrowed) / weightedCollateral · 100
854
+ else -> 0
855
+
856
+ Result clamped to [0, 100]. `assets` is a list of
857
+ {"symbol", "dc", "supplied_usd", "borrowed_usd"}; missing usd legs count as 0.
858
+ For a uniform-dc single-collateral position this reduces to the familiar
859
+ (max_debt - debt)/max_debt·100 with max_debt = equity·dc/(1-dc).
847
860
  """
848
- if max_mult is None:
849
- max_mult = DEGEN_MAX_MULT
850
- supplied_usd = sum(r.get("usd", 0) or 0 for r in supplied)
851
- debt_usd = sum(r.get("usd", 0) or 0 for r in borrowed)
861
+ wc_plus = 0.0
862
+ wc_minus = 0.0
863
+ weighted_borrowed = 0.0
864
+ borrowed = 0.0
865
+ supplied_usd = 0.0
866
+ debt_usd = 0.0
867
+ for a in assets:
868
+ dc = a.get("dc", 0.0) or 0.0
869
+ sup = a.get("supplied_usd", 0.0) or 0.0
870
+ bor = a.get("borrowed_usd", 0.0) or 0.0
871
+ supplied_usd += sup
872
+ debt_usd += bor
873
+ net = sup - bor
874
+ if net > 0:
875
+ wc_plus += dc * net
876
+ elif net < 0:
877
+ wc_minus += dc * (-net)
878
+ weighted_borrowed += dc * bor
879
+ borrowed += bor
880
+ weighted_collateral = wc_plus - wc_minus
852
881
  equity = supplied_usd - debt_usd
853
-
854
- if equity <= 0.01:
855
- return {"health_pct": 0.0, "supplied_usd": round(supplied_usd, 2),
856
- "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
857
- "max_debt": 0.0, "error": "equity near zero"}
858
-
859
- max_debt = equity * (max_mult - 1)
860
- if max_debt > 0 and debt_usd >= 0:
861
- health_pct = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
882
+ if borrowed <= 0:
883
+ health_pct = 100.0
884
+ elif weighted_collateral > 0 and (weighted_collateral + weighted_borrowed) > borrowed:
885
+ health_pct = (weighted_collateral + weighted_borrowed - borrowed) / weighted_collateral * 100.0
862
886
  health_pct = max(0.0, min(100.0, health_pct))
863
887
  else:
864
- health_pct = 100.0
865
-
888
+ health_pct = 0.0
866
889
  return {"health_pct": round(health_pct, 1), "supplied_usd": round(supplied_usd, 2),
867
890
  "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
868
- "max_debt": round(max_debt, 2), "tier": "FIXED_5X"}
891
+ "weighted_collateral": round(weighted_collateral, 2),
892
+ "weighted_borrowed": round(weighted_borrowed, 2)}
893
+
894
+
895
+ _dc_cache = {}
896
+
897
+
898
+ def _resolve_debt_coverages(w3, symbols: list, tier_code: int = 0) -> dict:
899
+ """Per-asset debtCoverage read LIVE on-chain from the TokenManager, keyed by symbol.
900
+
901
+ Resolves each symbol to its token address via getAssetAddress(bytes32,true), then reads
902
+ the account's effective coverage: tieredDebtCoverage(tier, token) on Avalanche/Arbitrum
903
+ (the contract's getHealthMeter uses getPrimeLeverageTier() for exactly this), falling
904
+ back to the un-tiered debtCoverage(token). Cached per run keyed by (symbol, tier_code).
905
+ Batched through multicall so N assets cost ~2 eth_calls, not 2N. Symbols that don't
906
+ resolve get dc=0 (they contribute nothing — same as the contract skipping an unpriced
907
+ leg)."""
908
+ want = [s for s in dict.fromkeys(symbols) if s]
909
+ out = {}
910
+ missing = []
911
+ for s in want:
912
+ ck = (s, tier_code)
913
+ if ck in _dc_cache:
914
+ out[s] = _dc_cache[ck]
915
+ else:
916
+ missing.append(s)
917
+ if not missing:
918
+ return out
919
+ tm_abi = json.loads(
920
+ '[{"inputs":[{"name":"_asset","type":"bytes32"},{"name":"_active","type":"bool"}],'
921
+ '"name":"getAssetAddress","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"},'
922
+ '{"inputs":[{"name":"a","type":"address"}],"name":"debtCoverage","outputs":[{"type":"uint256"}],'
923
+ '"stateMutability":"view","type":"function"},'
924
+ '{"inputs":[{"name":"t","type":"uint8"},{"name":"a","type":"address"}],"name":"tieredDebtCoverage",'
925
+ '"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
926
+ tm = w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=tm_abi)
927
+ addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(s), True])[2:]))
928
+ for s in missing]
929
+ addr_res = multicall(w3, addr_legs)
930
+ addrs = {}
931
+ for s, (ok, rd) in zip(missing, addr_res):
932
+ try:
933
+ a = w3.codec.decode(["address"], rd)[0] if ok and rd else None
934
+ except Exception:
935
+ a = None
936
+ addrs[s] = a if a and int(a, 16) != 0 else None
937
+ resolvable = [s for s in missing if addrs[s]]
938
+ # Try tiered coverage first (Avalanche/Arbitrum); fall back per-asset to un-tiered.
939
+ tiered_legs = [(TOKEN_MANAGER, bytes.fromhex(
940
+ tm.encode_abi("tieredDebtCoverage", args=[tier_code, Web3.to_checksum_address(addrs[s])])[2:]))
941
+ for s in resolvable]
942
+ untiered_legs = [(TOKEN_MANAGER, bytes.fromhex(
943
+ tm.encode_abi("debtCoverage", args=[Web3.to_checksum_address(addrs[s])])[2:]))
944
+ for s in resolvable]
945
+ tiered_res = multicall(w3, tiered_legs) if tiered_legs else []
946
+ untiered_res = multicall(w3, untiered_legs) if untiered_legs else []
947
+ for i, s in enumerate(resolvable):
948
+ dc = 0.0
949
+ ok_t, rd_t = tiered_res[i]
950
+ if ok_t and rd_t:
951
+ try:
952
+ dc = w3.codec.decode(["uint256"], rd_t)[0] / 1e18
953
+ except Exception:
954
+ dc = 0.0
955
+ if dc <= 0:
956
+ ok_u, rd_u = untiered_res[i]
957
+ if ok_u and rd_u:
958
+ try:
959
+ dc = w3.codec.decode(["uint256"], rd_u)[0] / 1e18
960
+ except Exception:
961
+ dc = 0.0
962
+ _dc_cache[(s, tier_code)] = dc
963
+ out[s] = dc
964
+ for s in missing:
965
+ if s not in out:
966
+ _dc_cache[(s, tier_code)] = 0.0
967
+ out[s] = 0.0
968
+ return out
969
+
970
+
971
+ def _compute_health_pct(supplied: list, borrowed: list, w3=None, tier_code: int = 0) -> dict:
972
+ """Frontend-exact health (0-100%) for a Degen Account — wraps _health_meter_pct with
973
+ per-asset on-chain debtCoverage. 0% = liquidation, 100% = no debt.
974
+
975
+ DegenPrime renders getHealthMeter (HealthMeterFacetProd) just like DeltaPrime, but Base
976
+ has no PRIME leverage tier, so debtCoverage is the un-tiered TokenManager value (the
977
+ tiered getter reverts and _resolve_debt_coverages falls back to it automatically).
978
+
979
+ supplied/borrowed are rows carrying `usd` (and optionally a pre-resolved `dc`); pass `w3`
980
+ so dc can be read live when a row lacks it. Returns health_pct, supplied_usd, debt_usd,
981
+ equity, max_debt (display zero-crossing debt), tier, or error.
982
+ """
983
+ syms = list(dict.fromkeys([r["symbol"] for r in supplied + borrowed if r.get("symbol")]))
984
+ dc_map = {r["symbol"]: r["dc"] for r in supplied + borrowed if r.get("dc") is not None}
985
+ need = [s for s in syms if s not in dc_map]
986
+ if need and w3 is not None:
987
+ dc_map.update(_resolve_debt_coverages(w3, need, tier_code))
988
+ assets = []
989
+ for s in syms:
990
+ sup = sum(r.get("usd", 0) or 0 for r in supplied if r.get("symbol") == s)
991
+ bor = sum(r.get("usd", 0) or 0 for r in borrowed if r.get("symbol") == s)
992
+ assets.append({"symbol": s, "dc": dc_map.get(s, 0.0), "supplied_usd": sup, "borrowed_usd": bor})
993
+ res = _health_meter_pct(assets)
994
+ equity = res["equity"]
995
+ if equity <= 0.01:
996
+ return {"health_pct": 0.0, "supplied_usd": res["supplied_usd"],
997
+ "debt_usd": res["debt_usd"], "equity": equity,
998
+ "max_debt": 0.0, "tier": "FIXED", "error": "equity near zero"}
999
+ coll_usd = sum(a["supplied_usd"] for a in assets) or 0.0
1000
+ dc_eff = (sum(a["dc"] * a["supplied_usd"] for a in assets) / coll_usd) if coll_usd > 0 else 0.0
1001
+ max_debt = equity * dc_eff / (1.0 - dc_eff) if 0 < dc_eff < 1 else 0.0
1002
+ return {"health_pct": res["health_pct"], "supplied_usd": res["supplied_usd"],
1003
+ "debt_usd": res["debt_usd"], "equity": equity,
1004
+ "max_debt": round(max_debt, 2), "tier": "FIXED"}
869
1005
 
870
1006
 
871
1007
  def _redstone_fetch_packages(use_cache: bool = True) -> dict:
@@ -2046,18 +2182,27 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2046
2182
  requested_wei = to_wei_units(amount, cfg["decimals"])
2047
2183
  debt_wei = pool.functions.getBorrowed(pa_cs).call()
2048
2184
  in_acct_wei = account.functions.getBalance(asset_b32(symbol)).call()
2185
+ try:
2186
+ total_intent_wei = account.functions.getTotalIntentAmount(asset_b32(symbol)).call()
2187
+ except Exception:
2188
+ total_intent_wei = 0
2189
+ available_wei = in_acct_wei - total_intent_wei if in_acct_wei > total_intent_wei else 0
2049
2190
  if debt_wei == 0:
2050
2191
  print(f"No {symbol} debt to repay on Degen Account {pa}.")
2051
2192
  return
2052
- amount_wei = min(requested_wei, debt_wei, in_acct_wei)
2193
+ amount_wei = min(requested_wei, debt_wei, available_wei)
2053
2194
  if amount_wei == 0:
2054
- print(f"Repay {amount} {symbol}: in-account {symbol} balance is 0 - "
2055
- f"swap into {symbol} first (e.g. degenprime swap --to {symbol} --amount N --execute).")
2195
+ print(f"Repay {amount} {symbol}: available {symbol} balance is 0 "
2196
+ f"(total {in_acct_wei / 10**cfg['decimals']:.6f} minus "
2197
+ f"{total_intent_wei / 10**cfg['decimals']:.6f} pending withdrawal intent) — "
2198
+ f"swap into {symbol} first or wait for intents to mature.")
2056
2199
  return
2057
2200
  cap_notes = []
2058
2201
  if amount_wei < requested_wei:
2059
- if in_acct_wei < min(requested_wei, debt_wei):
2060
- cap_notes.append(f"in-account {symbol} only {in_acct_wei / 10**cfg['decimals']:.6f}")
2202
+ if available_wei < min(requested_wei, debt_wei):
2203
+ cap_notes.append(f"available {symbol} only {available_wei / 10**cfg['decimals']:.6f} "
2204
+ f"(total {in_acct_wei / 10**cfg['decimals']:.6f} minus "
2205
+ f"{total_intent_wei / 10**cfg['decimals']:.6f} pending intent)")
2061
2206
  if debt_wei < requested_wei:
2062
2207
  cap_notes.append(f"debt only {debt_wei / 10**cfg['decimals']:.6f} {symbol}")
2063
2208
 
@@ -2067,11 +2212,15 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2067
2212
  print(f" Capped from requested {amount}: {'; '.join(cap_notes)}")
2068
2213
  print(f" Calls repay(bytes32 '{symbol}', {amount_wei}) on the Degen Account")
2069
2214
  print(f" Current debt: {debt_wei / 10**cfg['decimals']:.6f} {symbol} | "
2070
- f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol}")
2215
+ f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol} | "
2216
+ f"available: {available_wei / 10**cfg['decimals']:.6f} {symbol}")
2071
2217
  if in_acct_wei < debt_wei:
2072
2218
  shortfall = (debt_wei - in_acct_wei) / 10**cfg['decimals']
2073
- print(f" Note: in-account < debt by {shortfall:.6f} {symbol} - "
2219
+ print(f" Note: in-account < debt by {shortfall:.6f} {symbol} "
2074
2220
  f"swap into {symbol} first to close the position fully.")
2221
+ if total_intent_wei > 0:
2222
+ print(f" Note: {total_intent_wei / 10**cfg['decimals']:.6f} {symbol} is locked in pending "
2223
+ f"withdrawal intent(s) and not available for repay.")
2075
2224
  print("Run with --execute to broadcast")
2076
2225
  return
2077
2226
 
@@ -2098,6 +2247,53 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2098
2247
  repaid = amount_wei / 10**cfg['decimals']
2099
2248
  print(f"{'✓' if ok else '✗'} Repay {repaid:.6f} {symbol} {'confirmed' if ok else 'failed'}")
2100
2249
  print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2250
+ if not ok:
2251
+ _print_revert_reason(w3, tx, receipt)
2252
+
2253
+ def _print_revert_reason(w3, tx, receipt):
2254
+ """Try to decode and print the revert reason from a failed tx."""
2255
+ try:
2256
+ result = w3.eth.call({
2257
+ "from": tx["from"], "to": tx["to"], "data": tx["input"],
2258
+ "gas": receipt["gasUsed"],
2259
+ "maxFeePerGas": tx.get("maxFeePerGas", tx.get("gasPrice", 0)),
2260
+ "maxPriorityFeePerGas": tx.get("maxPriorityFeePerGas", 0),
2261
+ }, receipt["blockNumber"])
2262
+ except Exception as e:
2263
+ err = str(e)
2264
+ data = err
2265
+ if isinstance(e.args, (list, tuple)):
2266
+ for arg in e.args:
2267
+ if isinstance(arg, str) and arg.startswith("0x"):
2268
+ data = arg
2269
+ break
2270
+ elif isinstance(arg, dict) and "data" in arg:
2271
+ data = arg["data"]
2272
+ break
2273
+ if isinstance(data, str) and data.startswith("0x") and len(data) >= 10:
2274
+ sel = data[:10]
2275
+ _known_errors = {
2276
+ "0x567fe27a": "Unknown error from Prime Account facet",
2277
+ "0xf4d678b8": "Execution rejected (may be intent-locked balance check)",
2278
+ "0x441a702e": "InsufficientBalance()",
2279
+ "0xfd36fde3": "SignerNotAuthorised (RedStone signer mismatch)",
2280
+ "0x92ba160c": "RedstoneConsensus()",
2281
+ "0xc2c286b7": "SignerNotAuthorised(address)",
2282
+ "0x08c379a0": "require() revert: see message below",
2283
+ }
2284
+ label = _known_errors.get(sel, f"Unknown custom error 0x{sel}")
2285
+ print(f" Revert: {label}")
2286
+ if sel == "0x08c379a0" and len(data) >= 138:
2287
+ try:
2288
+ from eth_abi import decode
2289
+ msg_bytes = bytes.fromhex(data[10:])
2290
+ decoded = decode(["string"], msg_bytes)
2291
+ print(f' Require message: "{decoded[0]}"')
2292
+ except Exception:
2293
+ pass
2294
+ else:
2295
+ print(f" Revert reason: {err[:200]}")
2296
+
2101
2297
 
2102
2298
  # ─── ParaSwap / Velora route ─────────────────────────────────────────────────
2103
2299
  # The Degen Account already holds the funds, so the facet (not the EOA) approves the
@@ -1939,8 +1939,21 @@ def gather_lending(w3, account):
1939
1939
  sym = n.rstrip(b"\x00").decode(errors="replace")
1940
1940
  if sym and sym not in feeds:
1941
1941
  feeds.append(sym)
1942
- out = {"supplied": supplied, "borrowed": borrowed,
1942
+ out = {"supplied": supplied, "borrowed": borrowed, "w3": w3,
1943
1943
  "total_value_usd": None, "debt_usd": None, "health_ratio": None, "solvent": None}
1944
+ # Resolve the account's PRIME tier (oracle-free) and per-asset on-chain debtCoverage,
1945
+ # then stamp dc onto every row so _compute_health_pct can run getHealthMeter exactly.
1946
+ try:
1947
+ tier_code = account.functions.getLeverageTierFullInfo().call()[0]
1948
+ except Exception:
1949
+ tier_code = 0
1950
+ out["tier_code"] = tier_code
1951
+ try:
1952
+ dc_map = _resolve_debt_coverages(w3, [r["symbol"] for r in supplied + borrowed], tier_code)
1953
+ for r in supplied + borrowed:
1954
+ r["dc"] = dc_map.get(r["symbol"], 0.0)
1955
+ except Exception:
1956
+ pass
1944
1957
  try:
1945
1958
  payload = build_redstone_payload(feeds)
1946
1959
  payload_hex = payload.hex()
@@ -2001,46 +2014,188 @@ def gather_lending(w3, account):
2001
2014
  r["usd"] = None
2002
2015
  return out
2003
2016
 
2017
+ def _health_meter_pct(assets: list) -> dict:
2018
+ """getHealthMeter() exactly as the on-chain HealthMeterFacetProd renders it.
2019
+
2020
+ The frontend health meter is NOT equity*(mult-1); it is a per-asset, debtCoverage-
2021
+ weighted formula. For each asset i with USD-valued long balance and borrow, and its
2022
+ live debtCoverage dc_i:
2023
+
2024
+ net_i = supplied_usd_i - borrowed_usd_i
2025
+ weightedCollateralPlus = Σ dc_i·net_i for net_i > 0 (net-long legs)
2026
+ weightedCollateralMinus = Σ dc_i·(-net_i) for net_i < 0 (net-short legs)
2027
+ weightedCollateral = weightedCollateralPlus - weightedCollateralMinus
2028
+ weightedBorrowed = Σ dc_i·borrowed_usd_i
2029
+ borrowed = Σ borrowed_usd_i (UNWEIGHTED)
2030
+
2031
+ borrowed == 0 -> 100
2032
+ weightedCollateral > 0 and
2033
+ weightedCollateral + weightedBorrowed > borrowed
2034
+ -> (weightedCollateral + weightedBorrowed - borrowed) / weightedCollateral · 100
2035
+ else -> 0
2036
+
2037
+ Result clamped to [0, 100]. `assets` is a list of
2038
+ {"symbol", "dc", "supplied_usd", "borrowed_usd"}; missing usd legs count as 0.
2039
+ For a uniform-dc single-collateral position this reduces to the familiar
2040
+ (max_debt - debt)/max_debt·100 with max_debt = equity·dc/(1-dc).
2041
+ """
2042
+ wc_plus = 0.0
2043
+ wc_minus = 0.0
2044
+ weighted_borrowed = 0.0
2045
+ borrowed = 0.0
2046
+ supplied_usd = 0.0
2047
+ debt_usd = 0.0
2048
+ for a in assets:
2049
+ dc = a.get("dc", 0.0) or 0.0
2050
+ sup = a.get("supplied_usd", 0.0) or 0.0
2051
+ bor = a.get("borrowed_usd", 0.0) or 0.0
2052
+ supplied_usd += sup
2053
+ debt_usd += bor
2054
+ net = sup - bor
2055
+ if net > 0:
2056
+ wc_plus += dc * net
2057
+ elif net < 0:
2058
+ wc_minus += dc * (-net)
2059
+ weighted_borrowed += dc * bor
2060
+ borrowed += bor
2061
+ weighted_collateral = wc_plus - wc_minus
2062
+ equity = supplied_usd - debt_usd
2063
+ if borrowed <= 0:
2064
+ health_pct = 100.0
2065
+ elif weighted_collateral > 0 and (weighted_collateral + weighted_borrowed) > borrowed:
2066
+ health_pct = (weighted_collateral + weighted_borrowed - borrowed) / weighted_collateral * 100.0
2067
+ health_pct = max(0.0, min(100.0, health_pct))
2068
+ else:
2069
+ health_pct = 0.0
2070
+ return {"health_pct": round(health_pct, 1), "supplied_usd": round(supplied_usd, 2),
2071
+ "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2072
+ "weighted_collateral": round(weighted_collateral, 2),
2073
+ "weighted_borrowed": round(weighted_borrowed, 2)}
2074
+
2075
+
2076
+ _dc_cache = {}
2077
+
2078
+
2079
+ def _resolve_debt_coverages(w3, symbols: list, tier_code: int = 0) -> dict:
2080
+ """Per-asset debtCoverage read LIVE on-chain from the TokenManager, keyed by symbol.
2081
+
2082
+ Resolves each symbol to its token address via getAssetAddress(bytes32,true), then reads
2083
+ the account's effective coverage: tieredDebtCoverage(tier, token) on Avalanche/Arbitrum
2084
+ (the contract's getHealthMeter uses getPrimeLeverageTier() for exactly this), falling
2085
+ back to the un-tiered debtCoverage(token). Cached per run keyed by (symbol, tier_code).
2086
+ Batched through multicall so N assets cost ~2 eth_calls, not 2N. Symbols that don't
2087
+ resolve get dc=0 (they contribute nothing — same as the contract skipping an unpriced
2088
+ leg)."""
2089
+ want = [s for s in dict.fromkeys(symbols) if s]
2090
+ out = {}
2091
+ missing = []
2092
+ for s in want:
2093
+ ck = (s, tier_code)
2094
+ if ck in _dc_cache:
2095
+ out[s] = _dc_cache[ck]
2096
+ else:
2097
+ missing.append(s)
2098
+ if not missing:
2099
+ return out
2100
+ tm_abi = json.loads(
2101
+ '[{"inputs":[{"name":"_asset","type":"bytes32"},{"name":"_active","type":"bool"}],'
2102
+ '"name":"getAssetAddress","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"},'
2103
+ '{"inputs":[{"name":"a","type":"address"}],"name":"debtCoverage","outputs":[{"type":"uint256"}],'
2104
+ '"stateMutability":"view","type":"function"},'
2105
+ '{"inputs":[{"name":"t","type":"uint8"},{"name":"a","type":"address"}],"name":"tieredDebtCoverage",'
2106
+ '"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2107
+ tm = w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=tm_abi)
2108
+ addr_legs = [(TOKEN_MANAGER, bytes.fromhex(tm.encode_abi("getAssetAddress", args=[asset_b32(s), True])[2:]))
2109
+ for s in missing]
2110
+ addr_res = multicall(w3, addr_legs)
2111
+ addrs = {}
2112
+ for s, (ok, rd) in zip(missing, addr_res):
2113
+ try:
2114
+ a = w3.codec.decode(["address"], rd)[0] if ok and rd else None
2115
+ except Exception:
2116
+ a = None
2117
+ addrs[s] = a if a and int(a, 16) != 0 else None
2118
+ resolvable = [s for s in missing if addrs[s]]
2119
+ # Try tiered coverage first (Avalanche/Arbitrum); fall back per-asset to un-tiered.
2120
+ tiered_legs = [(TOKEN_MANAGER, bytes.fromhex(
2121
+ tm.encode_abi("tieredDebtCoverage", args=[tier_code, Web3.to_checksum_address(addrs[s])])[2:]))
2122
+ for s in resolvable]
2123
+ untiered_legs = [(TOKEN_MANAGER, bytes.fromhex(
2124
+ tm.encode_abi("debtCoverage", args=[Web3.to_checksum_address(addrs[s])])[2:]))
2125
+ for s in resolvable]
2126
+ tiered_res = multicall(w3, tiered_legs) if tiered_legs else []
2127
+ untiered_res = multicall(w3, untiered_legs) if untiered_legs else []
2128
+ for i, s in enumerate(resolvable):
2129
+ dc = 0.0
2130
+ ok_t, rd_t = tiered_res[i]
2131
+ if ok_t and rd_t:
2132
+ try:
2133
+ dc = w3.codec.decode(["uint256"], rd_t)[0] / 1e18
2134
+ except Exception:
2135
+ dc = 0.0
2136
+ if dc <= 0:
2137
+ ok_u, rd_u = untiered_res[i]
2138
+ if ok_u and rd_u:
2139
+ try:
2140
+ dc = w3.codec.decode(["uint256"], rd_u)[0] / 1e18
2141
+ except Exception:
2142
+ dc = 0.0
2143
+ _dc_cache[(s, tier_code)] = dc
2144
+ out[s] = dc
2145
+ for s in missing:
2146
+ if s not in out:
2147
+ _dc_cache[(s, tier_code)] = 0.0
2148
+ out[s] = 0.0
2149
+ return out
2150
+
2151
+
2004
2152
  def _compute_health_pct(data: dict, tier_code: int = 0) -> dict:
2005
- """Compute equity-based health (0-100%) from gather_lending data + tier.
2153
+ """Frontend-exact health (0-100%) for a Prime Account — wraps _health_meter_pct with
2154
+ on-chain debtCoverage resolution and the tier label.
2006
2155
 
2007
2156
  DeltaPrime has *two* health metrics that agents must not confuse:
2008
2157
 
2009
2158
  1. health_ratio (on-chain, getHealthRatio): 1.0 = liquidation, >1.0 = solvent.
2010
- This is the raw weighted-collateral / debt ratio from the SolvencyFacet.
2011
-
2012
- 2. health_pct (equity-based, 0-100%): the scale used in the DeltaPrime frontend
2013
- and the account-health-monitor cron. 0% = liquidation, 100% = no debt.
2014
- Formula:
2015
- equity = supplied_usd - debt_usd
2016
- max_mult = 10 if PREMIUM tier else 5 if BASIC
2017
- max_debt = equity * (max_mult - 1)
2018
- health_pct = (max_debt - debt_usd) / max_debt * 100
2019
-
2020
- Returns dict with keys: health_pct, supplied_usd, debt_usd, equity, max_debt,
2021
- tier_label, or error.
2159
+ The raw weighted-collateral / debt ratio from the SolvencyFacet.
2160
+
2161
+ 2. health_pct (0-100%, getHealthMeter): the scale the DeltaPrime frontend renders
2162
+ and the account-health-monitor cron acts on. 0% = liquidation, 100% = no debt.
2163
+ Computed by _health_meter_pct with per-asset dc from tieredDebtCoverage at the
2164
+ account's PRIME tier — NOT the old equity*(mult-1) approximation.
2165
+
2166
+ Per-asset USD comes from gather_lending (rows carry `dc` once resolved); if a row has
2167
+ no `dc` key yet it is resolved here from `data["w3"]` when present. Returns
2168
+ health_pct, supplied_usd, debt_usd, equity, max_debt (display zero-crossing debt),
2169
+ tier, or error.
2022
2170
  """
2023
- supplied_usd = sum(r.get("usd", 0) or 0 for r in data.get("supplied", []))
2024
- debt_usd = sum(r.get("usd", 0) or 0 for r in data.get("borrowed", []))
2025
- equity = supplied_usd - debt_usd
2171
+ supplied = data.get("supplied", [])
2172
+ borrowed = data.get("borrowed", [])
2026
2173
  tier_labels = {0: "BASIC", 1: "PREMIUM", 2: "_NON_EXISTENT"}
2027
2174
  tier_label = tier_labels.get(tier_code, str(tier_code))
2028
- max_mult = {0: 5, 1: 10}.get(tier_code, 5)
2029
-
2175
+ # Per-symbol long/short USD, merging an asset that is both supplied and borrowed.
2176
+ syms = list(dict.fromkeys([r["symbol"] for r in supplied + borrowed if r.get("symbol")]))
2177
+ dc_map = {r["symbol"]: r["dc"] for r in supplied + borrowed if r.get("dc") is not None}
2178
+ need = [s for s in syms if s not in dc_map]
2179
+ if need and data.get("w3") is not None:
2180
+ dc_map.update(_resolve_debt_coverages(data["w3"], need, tier_code))
2181
+ assets = []
2182
+ for s in syms:
2183
+ sup = sum(r.get("usd", 0) or 0 for r in supplied if r.get("symbol") == s)
2184
+ bor = sum(r.get("usd", 0) or 0 for r in borrowed if r.get("symbol") == s)
2185
+ assets.append({"symbol": s, "dc": dc_map.get(s, 0.0), "supplied_usd": sup, "borrowed_usd": bor})
2186
+ res = _health_meter_pct(assets)
2187
+ equity = res["equity"]
2030
2188
  if equity <= 0.01:
2031
- return {"health_pct": 0.0, "supplied_usd": round(supplied_usd, 2),
2032
- "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2189
+ return {"health_pct": 0.0, "supplied_usd": res["supplied_usd"],
2190
+ "debt_usd": res["debt_usd"], "equity": equity,
2033
2191
  "max_debt": 0.0, "tier": tier_label, "error": "equity near zero"}
2034
-
2035
- max_debt = equity * (max_mult - 1)
2036
- if max_debt > 0 and debt_usd >= 0:
2037
- health_pct = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
2038
- health_pct = max(0.0, min(100.0, health_pct))
2039
- else:
2040
- health_pct = 100.0
2041
-
2042
- return {"health_pct": round(health_pct, 1), "supplied_usd": round(supplied_usd, 2),
2043
- "debt_usd": round(debt_usd, 2), "equity": round(equity, 2),
2192
+ # Display-only zero-crossing debt: the unweighted borrow at which health hits 0,
2193
+ # equity·dc_eff/(1-dc_eff) for the position's value-weighted collateral dc.
2194
+ coll_usd = sum(a["supplied_usd"] for a in assets) or 0.0
2195
+ dc_eff = (sum(a["dc"] * a["supplied_usd"] for a in assets) / coll_usd) if coll_usd > 0 else 0.0
2196
+ max_debt = equity * dc_eff / (1.0 - dc_eff) if 0 < dc_eff < 1 else 0.0
2197
+ return {"health_pct": res["health_pct"], "supplied_usd": res["supplied_usd"],
2198
+ "debt_usd": res["debt_usd"], "equity": equity,
2044
2199
  "max_debt": round(max_debt, 2), "tier": tier_label}
2045
2200
 
2046
2201
 
@@ -2166,21 +2321,32 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2166
2321
  # The facet's repay reverts if amount > debt OR amount > in-account balance.
2167
2322
  # Cap to min(requested, debt, in_account) so callers don't need to know either
2168
2323
  # exact figure — pass an overshoot like 9999 and it clips cleanly.
2324
+ # ALSO: the contract's _getAvailableBalance() subtracts pending withdrawal intents,
2325
+ # so we subtract total_intent from the raw balance to get the true cap.
2169
2326
  requested_wei = to_wei_units(amount, cfg["decimals"])
2170
2327
  debt_wei = pool.functions.getBorrowed(pa_cs).call()
2171
2328
  in_acct_wei = account.functions.getBalance(asset_b32(symbol)).call()
2329
+ try:
2330
+ total_intent_wei = account.functions.getTotalIntentAmount(asset_b32(symbol)).call()
2331
+ except Exception:
2332
+ total_intent_wei = 0
2333
+ available_wei = in_acct_wei - total_intent_wei if in_acct_wei > total_intent_wei else 0
2172
2334
  if debt_wei == 0:
2173
2335
  print(f"No {symbol} debt to repay on Prime Account {pa}.")
2174
2336
  return
2175
- amount_wei = min(requested_wei, debt_wei, in_acct_wei)
2337
+ amount_wei = min(requested_wei, debt_wei, available_wei)
2176
2338
  if amount_wei == 0:
2177
- print(f"Repay {amount} {symbol}: in-account {symbol} balance is 0 "
2178
- f"swap into {symbol} first (e.g. deltaprime.py swap --to {symbol} --amount N --execute).")
2339
+ print(f"Repay {amount} {symbol}: available {symbol} balance is 0 "
2340
+ f"(total {in_acct_wei / 10**cfg['decimals']:.6f} minus "
2341
+ f"{total_intent_wei / 10**cfg['decimals']:.6f} pending withdrawal intent) — "
2342
+ f"swap into {symbol} first or wait for intents to mature.")
2179
2343
  return
2180
2344
  cap_notes = []
2181
2345
  if amount_wei < requested_wei:
2182
- if in_acct_wei < min(requested_wei, debt_wei):
2183
- cap_notes.append(f"in-account {symbol} only {in_acct_wei / 10**cfg['decimals']:.6f}")
2346
+ if available_wei < min(requested_wei, debt_wei):
2347
+ cap_notes.append(f"available {symbol} only {available_wei / 10**cfg['decimals']:.6f} "
2348
+ f"(total {in_acct_wei / 10**cfg['decimals']:.6f} minus "
2349
+ f"{total_intent_wei / 10**cfg['decimals']:.6f} pending intent)")
2184
2350
  if debt_wei < requested_wei:
2185
2351
  cap_notes.append(f"debt only {debt_wei / 10**cfg['decimals']:.6f} {symbol}")
2186
2352
 
@@ -2190,11 +2356,15 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2190
2356
  print(f" Capped from requested {amount}: {'; '.join(cap_notes)}")
2191
2357
  print(f" Calls repay(bytes32 '{symbol}', {amount_wei}) on the Prime Account")
2192
2358
  print(f" Current debt: {debt_wei / 10**cfg['decimals']:.6f} {symbol} | "
2193
- f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol}")
2359
+ f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol} | "
2360
+ f"available: {available_wei / 10**cfg['decimals']:.6f} {symbol}")
2194
2361
  if in_acct_wei < debt_wei:
2195
2362
  shortfall = (debt_wei - in_acct_wei) / 10**cfg['decimals']
2196
2363
  print(f" Note: in-account < debt by {shortfall:.6f} {symbol} — "
2197
2364
  f"swap into {symbol} first to close the position fully.")
2365
+ if total_intent_wei > 0:
2366
+ print(f" Note: {total_intent_wei / 10**cfg['decimals']:.6f} {symbol} is locked in pending "
2367
+ f"withdrawal intent(s) and not available for repay.")
2198
2368
  print("Run with --execute to broadcast")
2199
2369
  return
2200
2370
 
@@ -2221,6 +2391,53 @@ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
2221
2391
  repaid = amount_wei / 10**cfg['decimals']
2222
2392
  print(f"{'✓' if ok else '✗'} Repay {repaid:.6f} {symbol} {'confirmed' if ok else 'failed'}")
2223
2393
  print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2394
+ if not ok:
2395
+ _print_revert_reason(w3, tx, receipt)
2396
+
2397
+ def _print_revert_reason(w3, tx, receipt):
2398
+ """Try to decode and print the revert reason from a failed tx."""
2399
+ try:
2400
+ result = w3.eth.call({
2401
+ "from": tx["from"], "to": tx["to"], "data": tx["input"],
2402
+ "gas": receipt["gasUsed"],
2403
+ "maxFeePerGas": tx.get("maxFeePerGas", tx.get("gasPrice", 0)),
2404
+ "maxPriorityFeePerGas": tx.get("maxPriorityFeePerGas", 0),
2405
+ }, receipt["blockNumber"])
2406
+ except Exception as e:
2407
+ err = str(e)
2408
+ data = err
2409
+ if isinstance(e.args, (list, tuple)):
2410
+ for arg in e.args:
2411
+ if isinstance(arg, str) and arg.startswith("0x"):
2412
+ data = arg
2413
+ break
2414
+ elif isinstance(arg, dict) and "data" in arg:
2415
+ data = arg["data"]
2416
+ break
2417
+ if isinstance(data, str) and data.startswith("0x") and len(data) >= 10:
2418
+ sel = data[:10]
2419
+ _known_errors = {
2420
+ "0x567fe27a": "Unknown error from Prime Account facet",
2421
+ "0xf4d678b8": "Execution rejected (may be intent-locked balance check)",
2422
+ "0x441a702e": "InsufficientBalance()",
2423
+ "0xfd36fde3": "SignerNotAuthorised (RedStone signer mismatch)",
2424
+ "0x92ba160c": "RedstoneConsensus()",
2425
+ "0xc2c286b7": "SignerNotAuthorised(address)",
2426
+ "0x08c379a0": "require() revert: see message below",
2427
+ }
2428
+ label = _known_errors.get(sel, f"Unknown custom error 0x{sel}")
2429
+ print(f" Revert: {label}")
2430
+ if sel == "0x08c379a0" and len(data) >= 138:
2431
+ try:
2432
+ from eth_abi import decode
2433
+ msg_bytes = bytes.fromhex(data[10:])
2434
+ decoded = decode(["string"], msg_bytes)
2435
+ print(f' Require message: "{decoded[0]}"')
2436
+ except Exception:
2437
+ pass
2438
+ else:
2439
+ print(f" Revert reason: {err[:200]}")
2440
+
2224
2441
 
2225
2442
  def _decode_formatted_offer(raw: bytes):
2226
2443
  """Manually decode YieldYak's FormattedOffer struct
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.6.0
3
+ Version: 0.6.1
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.6.0"
7
+ version = "0.6.1"
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