primecli 0.10.0__tar.gz → 0.10.2__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 (32) hide show
  1. {primecli-0.10.0 → primecli-0.10.2}/PKG-INFO +2 -2
  2. {primecli-0.10.0 → primecli-0.10.2}/README.md +1 -1
  3. primecli-0.10.2/primecli/_flowledger.py +172 -0
  4. primecli-0.10.2/primecli/_wallets.py +125 -0
  5. {primecli-0.10.0 → primecli-0.10.2}/primecli/arbprime.py +235 -4
  6. {primecli-0.10.0 → primecli-0.10.2}/primecli/degenprime.py +1372 -107
  7. {primecli-0.10.0 → primecli-0.10.2}/primecli/deltaprime.py +252 -5
  8. {primecli-0.10.0 → primecli-0.10.2}/primecli/health_monitor.py +2 -2
  9. {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/PKG-INFO +2 -2
  10. {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/SOURCES.txt +3 -0
  11. {primecli-0.10.0 → primecli-0.10.2}/pyproject.toml +1 -1
  12. primecli-0.10.2/tests/test_aero_range_and_swap_fallback.py +201 -0
  13. primecli-0.10.2/tests/test_flowledger_transferred_amount.py +102 -0
  14. {primecli-0.10.0 → primecli-0.10.2}/tests/test_gas_limit.py +9 -6
  15. primecli-0.10.0/primecli/_wallets.py +0 -46
  16. {primecli-0.10.0 → primecli-0.10.2}/LICENSE +0 -0
  17. {primecli-0.10.0 → primecli-0.10.2}/primecli/__init__.py +0 -0
  18. {primecli-0.10.0 → primecli-0.10.2}/primecli/bridge.py +0 -0
  19. {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/dependency_links.txt +0 -0
  20. {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/entry_points.txt +0 -0
  21. {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/requires.txt +0 -0
  22. {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/top_level.txt +0 -0
  23. {primecli-0.10.0 → primecli-0.10.2}/setup.cfg +0 -0
  24. {primecli-0.10.0 → primecli-0.10.2}/tests/test_aero_rebalance.py +0 -0
  25. {primecli-0.10.0 → primecli-0.10.2}/tests/test_bridge.py +0 -0
  26. {primecli-0.10.0 → primecli-0.10.2}/tests/test_cross_file_identity.py +0 -0
  27. {primecli-0.10.0 → primecli-0.10.2}/tests/test_gas_pricing.py +0 -0
  28. {primecli-0.10.0 → primecli-0.10.2}/tests/test_health_meter.py +0 -0
  29. {primecli-0.10.0 → primecli-0.10.2}/tests/test_health_monitor.py +0 -0
  30. {primecli-0.10.0 → primecli-0.10.2}/tests/test_paraswap_validator.py +0 -0
  31. {primecli-0.10.0 → primecli-0.10.2}/tests/test_redstone_encoding.py +0 -0
  32. {primecli-0.10.0 → primecli-0.10.2}/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.10.0
3
+ Version: 0.10.2
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.10.0 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.10.2 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.10.0 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.10.2 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
 
@@ -0,0 +1,172 @@
1
+ """Live external-flow ledger appends for the PnL flow ledgers.
2
+
3
+ Going-forward counterpart to scripts/pnl_backfill.py (which reconstructs history
4
+ by scanning Diamond event logs). When a fund or withdrawal-execute broadcast
5
+ succeeds, the calling tool appends one record here so the flow is captured at the
6
+ moment it happens — no rescan of the chain needed later. Borrow/repay and
7
+ internal swaps/rebalances are NOT flows (they don't cross the account boundary);
8
+ only the fund/withdraw paths call in (methodology: docs/yield-pnl-methodology.md §3).
9
+
10
+ The record schema and the `<chain>__<account.lower()>.jsonl` file naming are
11
+ IDENTICAL to what pnl_backfill writes, so pnlctl and the backfill consume one
12
+ shared ledger. Records carry: ts(int), type("deposit"|"withdraw"), asset(str),
13
+ token_amount(float), usd_value(float|None), tx(str), block(int), source(str),
14
+ and an optional log_index(int) — omitted for live appends (a broadcast tx has no
15
+ single log index to attribute the flow to; the backfill fills it from getLogs).
16
+
17
+ FAILURE-ISOLATED BY CONTRACT: append_flow never raises. A logging error must not
18
+ fail a financial operation, so every path catches and downgrades to a stderr
19
+ warning. The caller wraps the call defensively too — belt and suspenders.
20
+ """
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ DEFAULT_FLOW_LEDGER_DIR = "/root/defi-sims/state/flow-ledgers"
28
+
29
+
30
+ def _ledger_dir(ledger_dir=None) -> Path:
31
+ if ledger_dir:
32
+ return Path(ledger_dir)
33
+ return Path(os.environ.get("FLOW_LEDGER_DIR", DEFAULT_FLOW_LEDGER_DIR))
34
+
35
+
36
+ def ledger_path(chain: str, account: str, ledger_dir=None) -> Path:
37
+ """Path to the JSONL ledger for (chain, account). The account is lowercased so
38
+ the filename matches pnl_backfill's `<chain>__<account.lower()>.jsonl`."""
39
+ return _ledger_dir(ledger_dir) / f"{chain}__{account.lower()}.jsonl"
40
+
41
+
42
+ def _dedupe_key(rec: dict) -> tuple:
43
+ """Identity of a live flow for idempotency: (tx, asset, type). A single fund or
44
+ withdrawal-execute broadcast moves one asset in one direction, so this uniquely
45
+ keys it without needing a log_index (which live appends don't carry)."""
46
+ return (rec.get("tx"), rec.get("asset"), rec.get("type"))
47
+
48
+
49
+ def append_flow(chain: str, account: str, record: dict, ledger_dir=None) -> bool:
50
+ """Append `record` to the (chain, account) flow ledger, idempotently.
51
+
52
+ Dedupes on (tx, asset, type): if a record with the same key is already present,
53
+ nothing is written (safe to call twice for the same broadcast). Creates the
54
+ ledger directory if missing and keeps the file sorted ascending by ts.
55
+
56
+ NEVER raises — any IO/serialisation error is swallowed with a stderr warning and
57
+ returns False. Returns True if a new record was written, False otherwise (already
58
+ present, or an error was swallowed).
59
+ """
60
+ try:
61
+ path = ledger_path(chain, account, ledger_dir)
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+
64
+ existing: list[dict] = []
65
+ if path.exists():
66
+ for line in path.read_text().splitlines():
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ existing.append(json.loads(line))
72
+ except json.JSONDecodeError:
73
+ print(f" WARN flowledger: skipping malformed line in {path}",
74
+ file=sys.stderr)
75
+
76
+ key = _dedupe_key(record)
77
+ if any(_dedupe_key(r) == key for r in existing):
78
+ return False # already logged this flow — idempotent no-op
79
+
80
+ merged = existing + [record]
81
+ merged.sort(key=lambda r: (r.get("ts", 0), r.get("block", 0)))
82
+
83
+ tmp = path.with_suffix(path.suffix + ".tmp")
84
+ tmp.write_text("\n".join(json.dumps(r) for r in merged) + "\n")
85
+ tmp.replace(path)
86
+ return True
87
+ except Exception as e: # noqa: BLE001 — logging must NEVER break the caller
88
+ print(f" WARN flowledger: failed to append flow ({type(e).__name__}: {e})",
89
+ file=sys.stderr)
90
+ return False
91
+
92
+
93
+ # ERC20 Transfer(address,address,uint256) event topic (keccak of the signature).
94
+ # Hardcoded so this module stays web3-free (it only does JSONL IO otherwise).
95
+ _ERC20_TRANSFER_TOPIC = (
96
+ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
97
+ )
98
+
99
+
100
+ def _to_hex(v) -> str:
101
+ """Normalise a web3 HexBytes / bytes / str to a 0x-prefixed lowercase hex string."""
102
+ h = v.hex() if hasattr(v, "hex") else str(v)
103
+ h = h.lower()
104
+ return h if h.startswith("0x") else "0x" + h
105
+
106
+
107
+ def _norm_addr(v) -> str:
108
+ """Last-20-bytes address as 0x-prefixed lowercase (handles 32-byte padded topics
109
+ and 20-byte log addresses alike)."""
110
+ return "0x" + _to_hex(v)[2:][-40:]
111
+
112
+
113
+ def transferred_amount(receipt, token_addr: str, from_addr: str, to_addr: str,
114
+ decimals: int):
115
+ """Real ERC20 amount moved `from_addr` -> `to_addr` in `token_addr` within this
116
+ receipt, in human units (raw / 10**decimals). Sums all matching Transfer logs.
117
+
118
+ Returns None when the receipt carries NO matching Transfer log at all — the caller
119
+ then falls back to the requested amount (can't determine the truth). A matching
120
+ Transfer of value 0 returns 0.0 (the fund pulled nothing), NOT None.
121
+
122
+ This is the truth source for a fund flow: `fund(asset, amount)` can pull LESS than
123
+ `amount` when the wallet's balance/allowance is short — e.g. a leverage-open where
124
+ most of the position is borrowed, so the EOA only holds dust. Logging the requested
125
+ arg then over-counts contribution and corrupts every downstream PnL/ROI/APR. Parsing
126
+ the actual on-chain Transfer fixes that at the source.
127
+ """
128
+ try:
129
+ token = _norm_addr(token_addr)
130
+ frm = _norm_addr(from_addr)
131
+ to = _norm_addr(to_addr)
132
+ total_raw = 0
133
+ found = False
134
+ for log in receipt["logs"]:
135
+ if _norm_addr(log["address"]) != token:
136
+ continue
137
+ topics = log["topics"]
138
+ if len(topics) != 3 or _to_hex(topics[0]) != _ERC20_TRANSFER_TOPIC:
139
+ continue
140
+ if _norm_addr(topics[1]) != frm or _norm_addr(topics[2]) != to:
141
+ continue
142
+ total_raw += int(_to_hex(log["data"]), 16)
143
+ found = True
144
+ if not found:
145
+ return None
146
+ return total_raw / (10 ** decimals)
147
+ except Exception as e: # noqa: BLE001 — never break the caller over a parse error
148
+ print(f" WARN flowledger: transfer parse failed ({type(e).__name__}: {e})",
149
+ file=sys.stderr)
150
+ return None
151
+
152
+
153
+ def make_record(*, ts: int, ftype: str, asset: str, token_amount: float,
154
+ usd_value, tx: str, block: int, source: str) -> dict:
155
+ """Build a ledger record with exactly the keys pnl_backfill writes (minus the
156
+ optional log_index, which live appends omit). Centralised so the three tools
157
+ construct byte-identical records."""
158
+ # web3.py 7.x HexBytes.hex() drops the 0x prefix; pnl_backfill normalises tx hashes
159
+ # to a 0x-prefixed string, so do the same here or the (tx, …) dedupe would never
160
+ # match a backfilled record for the same tx.
161
+ if tx and not tx.startswith("0x"):
162
+ tx = "0x" + tx
163
+ return {
164
+ "ts": int(ts),
165
+ "type": ftype,
166
+ "asset": asset,
167
+ "token_amount": float(token_amount),
168
+ "usd_value": (None if usd_value is None else float(usd_value)),
169
+ "tx": tx,
170
+ "block": int(block),
171
+ "source": source,
172
+ }
@@ -0,0 +1,125 @@
1
+ """Shared named-wallet key resolution for the primecli siblings.
2
+
3
+ Single source of truth for the agent→(env_file, var) secrets map and the
4
+ helpers that read a private key out of it. degenprime and the bridge command
5
+ import from here so the map is defined once; deltaprime/arbprime carry their own
6
+ historical copies (kept for now to stay surgical — see the note below).
7
+
8
+ Security: never log or echo the values these helpers return. They are raw
9
+ private keys.
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # HD wallet derivation from a BIP39 seed file.
16
+ # The seed file is a plaintext mnemonic with tight permissions (chmod 600).
17
+ # The derivation functions NEVER log or print the mnemonic.
18
+ # ---------------------------------------------------------------------------
19
+
20
+ _HD_DERIVE_CACHE = {}
21
+
22
+
23
+ def _ensure_hd_libs():
24
+ """Lazy-import HD derivation libs. Never echoes the seed."""
25
+ global _HD_DERIVE_CACHE
26
+ if "mnemonic" not in _HD_DERIVE_CACHE:
27
+ from mnemonic import Mnemonic
28
+ from bip32 import BIP32
29
+ _HD_DERIVE_CACHE["mnemonic"] = Mnemonic
30
+ _HD_DERIVE_CACHE["BIP32"] = BIP32
31
+
32
+
33
+ def _derive_private_key(seed_path: str, derivation_path: str) -> str:
34
+ """
35
+ Read the BIP39 seed file and derive the private key at `derivation_path`.
36
+ NEVER logs or returns the mnemonic.
37
+ """
38
+ _ensure_hd_libs()
39
+ Mnemonic = _HD_DERIVE_CACHE["mnemonic"]
40
+ BIP32 = _HD_DERIVE_CACHE["BIP32"]
41
+
42
+ raw = Path(seed_path).read_text().strip()
43
+ if not raw:
44
+ raise RuntimeError(f"Seed file {seed_path} is empty")
45
+ words = raw.split()
46
+ if len(words) not in (12, 15, 18, 21, 24):
47
+ raise RuntimeError(
48
+ f"Seed file {seed_path} has {len(words)} words; expected 12-24"
49
+ )
50
+
51
+ mnemo = Mnemonic("english")
52
+ if not mnemo.check(raw):
53
+ raise RuntimeError(f"Seed file {seed_path} contains an invalid mnemonic (checksum fail)")
54
+
55
+ seed = mnemo.to_seed(raw)
56
+ bip = BIP32.from_seed(seed)
57
+ privkey = bip.get_privkey_from_path(derivation_path)
58
+ return privkey.hex()
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Agent / wallet registry
63
+ #
64
+ # Tuple formats:
65
+ # (env_path, env_var) — raw key from env file (existing)
66
+ # (seed_path, None, deriv_path) — HD-derived from seed file
67
+ # The third element (None vs deriv_path) distinguishes the two modes.
68
+ # ---------------------------------------------------------------------------
69
+
70
+ AGENTS = {
71
+ # Raw key entries (from env files)
72
+ "parakletos": ("/root/.openclaw/.env", "PARAKLETOS_EVM_PRIVATE_KEY"),
73
+ "paraklaudios": ("/root/paraklaudios/.credentials.env", "PARAKLAUDIOS_EVM_PRIVATE_KEY"),
74
+ "core1": ("/root/.openclaw/.env", "BRUNO_CORE1_PRIVATE_KEY"),
75
+
76
+ # HD seed-derived entries (from Parakletos's BIP39 seed)
77
+ "parakletos-2": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/0"),
78
+ "parakletos-3": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/1"),
79
+ "parakletos-4": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/2"),
80
+ "parakletos-5": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/3"),
81
+ "parakletos-6": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/4"),
82
+ "parakletos-7": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/1'/0/0"),
83
+ "parakletos-8": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/2'/0/0"),
84
+ }
85
+
86
+ HD_AGENTS = {name for name, entry in AGENTS.items() if len(entry) == 3 and entry[1] is None}
87
+
88
+
89
+ def _read_env_var(path, var):
90
+ """Return the value of `var` from a KEY=VALUE env file, or None if absent."""
91
+ try:
92
+ for line in Path(path).read_text().splitlines():
93
+ s = line.strip()
94
+ if s.startswith(var + "="):
95
+ return s.split("=", 1)[1].strip().strip('"').strip("'")
96
+ except FileNotFoundError:
97
+ return None
98
+ return None
99
+
100
+
101
+ def _agent_key(agent):
102
+ if agent not in AGENTS:
103
+ raise RuntimeError(
104
+ f"Unknown agent '{agent}'. Known agents: {', '.join(AGENTS)}. "
105
+ f"Or set DEGENPRIME_PRIVATE_KEY, or DEGENPRIME_KEY_FILE."
106
+ )
107
+ entry = AGENTS[agent]
108
+
109
+ # HD wallet derivation
110
+ if len(entry) == 3 and entry[1] is None:
111
+ seed_path, _, deriv_path = entry
112
+ try:
113
+ return _derive_private_key(seed_path, deriv_path)
114
+ except RuntimeError as e:
115
+ # Re-raise with the agent name for context, but never leak the seed
116
+ raise RuntimeError(
117
+ f"Failed to derive key for agent '{agent}' (deriv_path={deriv_path}): {e}"
118
+ ) from e
119
+
120
+ # Raw key from env file (existing logic)
121
+ path, var = entry
122
+ key = _read_env_var(path, var)
123
+ if not key:
124
+ raise RuntimeError(f"{var} not found in {path} (agent '{agent}').")
125
+ return key
@@ -20,6 +20,7 @@ Usage:
20
20
  arbprime create-prime-account --fund-pool usdc --fund-amount 100 [--execute]
21
21
  arbprime prime-summary
22
22
  arbprime defi --json (aggregate ALL positions as DeBank-style JSON; read-only)
23
+ arbprime equity [--json] [--account 0x..] (net equity = total value - debt + unclaimed rewards; read-only)
23
24
  arbprime fund --pool usdc --amount 100 [--execute]
24
25
  arbprime borrow --pool usdc --amount 100 [--execute]
25
26
  arbprime repay --pool usdc --amount 100 [--execute]
@@ -208,6 +209,7 @@ from eth_account import Account
208
209
  from eth_keys import keys as eth_keys
209
210
  from eth_abi import encode as abi_encode, decode as abi_decode
210
211
  from web3 import Web3
212
+ from primecli import _flowledger
211
213
 
212
214
  # Health monitoring sub-system
213
215
  # Try both package import (installed) and local import (standalone script)
@@ -256,9 +258,52 @@ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
256
258
  # If none resolve, fail closed (no silent default key).
257
259
  #
258
260
  # To add another wallet: add a row to AGENTS, export ARBPRIME_PRIVATE_KEY, or pass --key.
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # HD wallet derivation from a BIP39 seed file.
264
+ # The seed file is a plaintext mnemonic with tight permissions (chmod 600).
265
+ # The derivation functions NEVER log or print the mnemonic.
266
+ # ---------------------------------------------------------------------------
267
+ _HD_DERIVE_CACHE = {}
268
+ def _ensure_hd_libs():
269
+ global _HD_DERIVE_CACHE
270
+ if "mnemonic" not in _HD_DERIVE_CACHE:
271
+ from mnemonic import Mnemonic
272
+ from bip32 import BIP32
273
+ _HD_DERIVE_CACHE["mnemonic"] = Mnemonic
274
+ _HD_DERIVE_CACHE["BIP32"] = BIP32
275
+ def _derive_private_key(seed_path, derivation_path):
276
+ _ensure_hd_libs()
277
+ Mnemonic = _HD_DERIVE_CACHE["mnemonic"]
278
+ BIP32 = _HD_DERIVE_CACHE["BIP32"]
279
+ raw = Path(seed_path).read_text().strip()
280
+ if not raw:
281
+ raise RuntimeError(f"Seed file {seed_path} is empty")
282
+ words = raw.split()
283
+ if len(words) not in (12, 15, 18, 21, 24):
284
+ raise RuntimeError(f"Seed file {seed_path} has {len(words)} words; expected 12-24")
285
+ mnemo = Mnemonic("english")
286
+ if not mnemo.check(raw):
287
+ raise RuntimeError(f"Seed file {seed_path} contains an invalid mnemonic (checksum fail)")
288
+ seed = mnemo.to_seed(raw)
289
+ bip = BIP32.from_seed(seed)
290
+ privkey = bip.get_privkey_from_path(derivation_path)
291
+ return privkey.hex()
292
+ # ---------------------------------------------------------------------------
293
+
259
294
  AGENTS = {
295
+ # Raw key entries (from env files)
260
296
  "parakletos": ("/root/.openclaw/.env", "PARAKLETOS_EVM_PRIVATE_KEY"),
261
297
  "paraklaudios": ("/root/paraklaudios/.credentials.env", "PARAKLAUDIOS_EVM_PRIVATE_KEY"),
298
+
299
+ # HD seed-derived entries (from Parakletos's BIP39 seed)
300
+ "parakletos-2": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/0"),
301
+ "parakletos-3": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/1"),
302
+ "parakletos-4": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/2"),
303
+ "parakletos-5": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/3"),
304
+ "parakletos-6": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/0'/0/4"),
305
+ "parakletos-7": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/1'/0/0"),
306
+ "parakletos-8": ("/root/.openclaw/workspace/config/wallet.seed", None, "m/44'/60'/2'/0/0"),
262
307
  }
263
308
  _SELECTED_AGENT = None # set by the --as CLI flag in main()
264
309
  _CLI_KEY = None # set by the --key CLI flag in main()
@@ -699,11 +744,17 @@ def _estimate_gas_limit(w3, tx_dict, fallback_gas: int, buffer_bps: int = 1250)
699
744
  Solvency-gated swap paths append RedStone payloads and can vary materially by
700
745
  route. A fixed cap can pass simulation at a high gas allowance, then revert
701
746
  out-of-gas on broadcast. If the RPC cannot estimate, keep the old fixed cap.
747
+
748
+ fallback_gas is a fallback for when estimation FAILS, not a floor that overrides a
749
+ good estimate. Flooring at e.g. 4M over-reserved gas_limit*maxFeePerGas upfront,
750
+ so a low-gas-balance wallet hit "insufficient funds for gas" even when the tx
751
+ itself simulated fine. A successful estimate is authoritative; the OOG retry in
752
+ _sign_and_send (+50% on gasUsed>=gasLimit) covers any under-estimate.
702
753
  """
703
754
  try:
704
755
  call_tx = {k: tx_dict[k] for k in ("from", "to", "data", "value") if k in tx_dict}
705
756
  estimated = int(w3.eth.estimate_gas(call_tx))
706
- return max(int(fallback_gas), (estimated * int(buffer_bps) + 999) // 1000)
757
+ return (estimated * int(buffer_bps) + 999) // 1000
707
758
  except Exception:
708
759
  return int(fallback_gas)
709
760
 
@@ -741,7 +792,18 @@ def _agent_key(agent):
741
792
  f"Unknown agent '{agent}'. Known agents: {', '.join(AGENTS)}. "
742
793
  f"Or set ARBPRIME_PRIVATE_KEY, or ARBPRIME_ENV_FILE + ARBPRIME_KEY_VAR."
743
794
  )
744
- path, var = AGENTS[agent]
795
+ entry = AGENTS[agent]
796
+ # HD wallet derivation (3-tuple with None in middle)
797
+ if len(entry) == 3 and entry[1] is None:
798
+ seed_path, _, deriv_path = entry
799
+ try:
800
+ return _derive_private_key(seed_path, deriv_path)
801
+ except RuntimeError as e:
802
+ raise RuntimeError(
803
+ f"Failed to derive key for agent '{agent}' (deriv_path={deriv_path}): {e}"
804
+ ) from e
805
+ # Raw key from env file (existing logic)
806
+ path, var = entry
745
807
  key = _read_env_var(path, var)
746
808
  if not key:
747
809
  raise RuntimeError(f"{var} not found in {path} (agent '{agent}').")
@@ -1903,8 +1965,51 @@ def cmd_fund(pool_name: str, amount: float, execute: bool = False):
1903
1965
  })
1904
1966
  receipt = _sign_and_send(w3, acct, fund_tx, f"Fund {amount} {symbol}", fallback_gas=3000000)
1905
1967
  ok = receipt["status"] == 1
1968
+ if ok:
1969
+ _log_fund_flow(
1970
+ pa, symbol, amount, receipt,
1971
+ token_addr=(None if cfg["native"] else cfg["token"]),
1972
+ from_addr=acct.address, decimals=cfg["decimals"],
1973
+ )
1906
1974
  return ok
1907
1975
 
1976
+
1977
+ def _log_fund_flow(account_addr: str, symbol: str, amount: float, receipt,
1978
+ token_addr: str = None, from_addr: str = None,
1979
+ decimals: int = None) -> None:
1980
+ """Append a 'deposit' flow record after a successful fund broadcast. asset is the
1981
+ funded bytes32 symbol (the wrapped-native symbol for native funding, e.g. 'ETH').
1982
+ usd_value uses the current spot price (≈ flow-time price); None when unpriced.
1983
+ Wrapped so a logging failure can never fail the financial op.
1984
+
1985
+ Logs the ACTUAL ERC20 Transfer(EOA -> account) amount from the receipt, not the
1986
+ requested `amount`: an ERC20 fund() can pull less than asked when the wallet is
1987
+ short (leverage-opens fund mostly from borrow), and logging the request over-counts
1988
+ contribution. Native funding moves the exact msg.value, so `amount` stands there
1989
+ (token_addr is None)."""
1990
+ try:
1991
+ actual = amount
1992
+ if token_addr and from_addr and decimals is not None:
1993
+ moved = _flowledger.transferred_amount(
1994
+ receipt, token_addr, from_addr, account_addr, decimals)
1995
+ if moved is not None:
1996
+ if abs(moved - amount) > max(1e-6, abs(amount) * 1e-4):
1997
+ print(f" NOTE fund pulled {moved} {symbol} of {amount} requested "
1998
+ f"(logging actual)", file=sys.stderr)
1999
+ actual = moved
2000
+ px = token_price(symbol)
2001
+ usd = round(actual * px, 8) if px else None
2002
+ rec = _flowledger.make_record(
2003
+ ts=int(time.time()), ftype="deposit", asset=symbol,
2004
+ token_amount=actual, usd_value=usd,
2005
+ tx=receipt["transactionHash"].hex(), block=receipt["blockNumber"],
2006
+ source="live-fund",
2007
+ )
2008
+ _flowledger.append_flow("arbitrum", account_addr, rec)
2009
+ except Exception as e: # noqa: BLE001 — never fail the fund over logging
2010
+ print(f" WARN flow logging skipped ({type(e).__name__}: {e})", file=sys.stderr)
2011
+
2012
+
1908
2013
  def _prices_usd(w3, account, symbols: list, payload: bytes) -> dict:
1909
2014
  """Best-effort per-symbol USD price map via the RedStone-gated getPrices view (1e8-scaled).
1910
2015
  Reuses an already-built `payload`; returns {symbol: float}. Any symbol whose feed is
@@ -2293,6 +2398,80 @@ def cmd_prime_summary():
2293
2398
  print(f" Health/solvency: RedStone fetch/call failed ({data.get('solvency_error', 'error')}); "
2294
2399
  "showing balances only")
2295
2400
 
2401
+ def get_account_equity(account_addr: str = None) -> dict:
2402
+ """Net equity of a Prime Account: total_value_usd - debt_usd + rewards_usd.
2403
+
2404
+ Read-only (eth_call only): reuses gather_lending for the RedStone-gated
2405
+ getTotalValue/getDebt reads. `account_addr` targets a specific Prime Account; when
2406
+ None it resolves the one owned by the --as / --owner wallet.
2407
+
2408
+ rewards_usd is 0.0 here: Arbitrum DeltaPrime has no separate unclaimed-reward
2409
+ primitive in this tool (no sJOE — that is the Avalanche TraderJoe path; GMX/GLV
2410
+ fees compound inside the LP and are already counted by getTotalValue). It stays in
2411
+ the dict for cross-tool shape parity.
2412
+
2413
+ Returns {agent, chain, protocol, wallet, prime_account, total_value_usd, debt_usd,
2414
+ rewards_usd, net_equity_usd, block, ts, status}. On any read error: status="error",
2415
+ an `error` field, and the numeric fields set to None."""
2416
+ base = {
2417
+ "agent": _SELECTED_AGENT, "chain": "arbitrum", "protocol": "DeltaPrime",
2418
+ "wallet": None, "prime_account": None,
2419
+ "total_value_usd": None, "debt_usd": None, "rewards_usd": None,
2420
+ "net_equity_usd": None, "block": None, "ts": int(time.time()), "status": "ok",
2421
+ }
2422
+ try:
2423
+ w3 = get_w3()
2424
+ acct = get_account()
2425
+ base["wallet"] = acct.address
2426
+ pa = account_addr or get_prime_account(w3, acct.address)
2427
+ base["block"] = w3.eth.block_number
2428
+ if not pa:
2429
+ base["status"] = "no_account"
2430
+ return base
2431
+ pa = Web3.to_checksum_address(pa)
2432
+ base["prime_account"] = pa
2433
+ account = w3.eth.contract(address=pa, abi=PRIME_ACCOUNT_ABI)
2434
+ lending = gather_lending(w3, account)
2435
+ if "solvency_error" in lending or lending.get("total_value_usd") is None:
2436
+ base["status"] = "error"
2437
+ base["error"] = lending.get("solvency_error", "solvency read failed")
2438
+ return base
2439
+ total_value = lending["total_value_usd"]
2440
+ debt = lending["debt_usd"]
2441
+ rewards = 0.0
2442
+ base["total_value_usd"] = round(total_value, 2)
2443
+ base["debt_usd"] = round(debt, 2)
2444
+ base["rewards_usd"] = round(rewards, 2)
2445
+ base["net_equity_usd"] = round(total_value - debt + rewards, 2)
2446
+ return base
2447
+ except Exception as e:
2448
+ base["status"] = "error"
2449
+ base["error"] = f"{type(e).__name__}: {e}"
2450
+ return base
2451
+
2452
+ def cmd_equity(as_json: bool = False, account_addr: str = None):
2453
+ """Print net equity for the selected wallet's Prime Account (read-only)."""
2454
+ eq = get_account_equity(account_addr)
2455
+ if as_json:
2456
+ print(json.dumps(eq, indent=2))
2457
+ return
2458
+ print(f"Owner wallet: {eq['wallet']}")
2459
+ if eq["status"] == "no_account":
2460
+ print("No Prime Account yet. Create one with: arbprime create-prime-account --execute")
2461
+ return
2462
+ print(f"Prime Account: {eq['prime_account']}")
2463
+ if eq["status"] == "error":
2464
+ print(f" Equity: unavailable ({eq.get('error', 'read failed')})")
2465
+ return
2466
+ tv, debt, rw, net = (eq["total_value_usd"], eq["debt_usd"],
2467
+ eq["rewards_usd"], eq["net_equity_usd"])
2468
+ health = (100.0 if debt < 0.01 else round((tv - debt) / tv * 100, 1)) if tv else 0.0
2469
+ print(f" Total value: ${tv:,.2f}")
2470
+ print(f" Debt: ${debt:,.2f}")
2471
+ print(f" Rewards: ${rw:,.2f} (no separate unclaimed-reward source on Arbitrum DeltaPrime)")
2472
+ print(f" Net equity: ${net:,.2f} (total - debt + rewards)")
2473
+ print(f" Equity health: {health:.1f}% (equity / total value)")
2474
+
2296
2475
  def cmd_borrow(pool_name: str, amount: float, execute: bool = False):
2297
2476
  cfg = POOLS[pool_name]
2298
2477
  w3 = get_w3()
@@ -3340,6 +3519,48 @@ def cmd_execute_withdrawal(pool_name: str, index: int = None, execute: bool = Fa
3340
3519
  }
3341
3520
  receipt = _sign_and_send(w3, acct, tx, "Execute withdrawal", fallback_gas=3000000)
3342
3521
  ok = receipt["status"] == 1
3522
+ if ok:
3523
+ executed_amount = sum(intents[i][0] for i in ready) / 10 ** cfg["decimals"]
3524
+ _log_withdraw_flow(
3525
+ pa, symbol, executed_amount, receipt,
3526
+ token_addr=(None if cfg["native"] else cfg["token"]),
3527
+ to_addr=acct.address, decimals=cfg["decimals"],
3528
+ )
3529
+
3530
+
3531
+ def _log_withdraw_flow(account_addr: str, symbol: str, amount: float, receipt,
3532
+ token_addr: str = None, to_addr: str = None,
3533
+ decimals: int = None) -> None:
3534
+ """Append a 'withdraw' flow record after a successful executeWithdrawalIntent
3535
+ broadcast (funds left the account to the EOA). amount is the total executed across
3536
+ the matured intents. usd_value uses current spot price; None when unpriced. Wrapped
3537
+ so a logging failure can never fail the financial op.
3538
+
3539
+ Prefers the ACTUAL ERC20 Transfer(account -> EOA) amount from the receipt over the
3540
+ committed `amount`, symmetric with the fund path: the executed amount is normally
3541
+ exact, but parsing the receipt keeps the ledger honest if it ever diverges. Native
3542
+ unwraps emit no ERC20 Transfer, so `amount` stands there (token_addr is None)."""
3543
+ try:
3544
+ actual = amount
3545
+ if token_addr and to_addr and decimals is not None:
3546
+ moved = _flowledger.transferred_amount(
3547
+ receipt, token_addr, account_addr, to_addr, decimals)
3548
+ if moved is not None:
3549
+ if abs(moved - amount) > max(1e-6, abs(amount) * 1e-4):
3550
+ print(f" NOTE withdraw moved {moved} {symbol} of {amount} executed "
3551
+ f"(logging actual)", file=sys.stderr)
3552
+ actual = moved
3553
+ px = token_price(symbol)
3554
+ usd = round(actual * px, 8) if px else None
3555
+ rec = _flowledger.make_record(
3556
+ ts=int(time.time()), ftype="withdraw", asset=symbol,
3557
+ token_amount=actual, usd_value=usd,
3558
+ tx=receipt["transactionHash"].hex(), block=receipt["blockNumber"],
3559
+ source="live-withdraw",
3560
+ )
3561
+ _flowledger.append_flow("arbitrum", account_addr, rec)
3562
+ except Exception as e: # noqa: BLE001 — never fail the withdrawal over logging
3563
+ print(f" WARN flow logging skipped ({type(e).__name__}: {e})", file=sys.stderr)
3343
3564
 
3344
3565
  # ─── GMX V2 GM / GM+ LP (GmxV2FacetArbitrum / GmxV2PlusFacetArbitrum) ─────────
3345
3566
  # GM tokens are GMX V2 market LP (two-sided long+short for GM, single-sided for GM+).
@@ -5630,8 +5851,8 @@ def main():
5630
5851
  return
5631
5852
 
5632
5853
  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")
5854
+ if _OWNER_ADDRESS and cmd not in {"defi", "lb-positions", "equity"}:
5855
+ print("--owner is only supported for read-only commands: defi, lb-positions, equity")
5635
5856
  return
5636
5857
  if cmd == "pool-info":
5637
5858
  # First positional after `pool-info` is the pool name; --json is an opt-in flag
@@ -5713,6 +5934,16 @@ def main():
5713
5934
  cmd_prime_summary()
5714
5935
  elif cmd == "defi":
5715
5936
  cmd_defi("--json" in args)
5937
+ elif cmd == "equity":
5938
+ account_addr = None
5939
+ for i, a in enumerate(args):
5940
+ if a == "--account" and i + 1 < len(args):
5941
+ try:
5942
+ account_addr = Web3.to_checksum_address(args[i + 1])
5943
+ except Exception:
5944
+ print(f"Invalid --account address: {args[i + 1]}")
5945
+ return
5946
+ cmd_equity("--json" in args, account_addr)
5716
5947
  elif cmd == "fund":
5717
5948
  pool, amount = None, None
5718
5949
  execute = "--execute" in args