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.
- {primecli-0.10.0 → primecli-0.10.2}/PKG-INFO +2 -2
- {primecli-0.10.0 → primecli-0.10.2}/README.md +1 -1
- primecli-0.10.2/primecli/_flowledger.py +172 -0
- primecli-0.10.2/primecli/_wallets.py +125 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli/arbprime.py +235 -4
- {primecli-0.10.0 → primecli-0.10.2}/primecli/degenprime.py +1372 -107
- {primecli-0.10.0 → primecli-0.10.2}/primecli/deltaprime.py +252 -5
- {primecli-0.10.0 → primecli-0.10.2}/primecli/health_monitor.py +2 -2
- {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/PKG-INFO +2 -2
- {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/SOURCES.txt +3 -0
- {primecli-0.10.0 → primecli-0.10.2}/pyproject.toml +1 -1
- primecli-0.10.2/tests/test_aero_range_and_swap_fallback.py +201 -0
- primecli-0.10.2/tests/test_flowledger_transferred_amount.py +102 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_gas_limit.py +9 -6
- primecli-0.10.0/primecli/_wallets.py +0 -46
- {primecli-0.10.0 → primecli-0.10.2}/LICENSE +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli/__init__.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli/bridge.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/setup.cfg +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_aero_rebalance.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_bridge.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_gas_pricing.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_health_meter.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_health_monitor.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.10.0 → primecli-0.10.2}/tests/test_redstone_encoding.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|