primecli 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {primecli-0.9.0 → primecli-0.10.0}/PKG-INFO +14 -3
  2. {primecli-0.9.0 → primecli-0.10.0}/README.md +13 -2
  3. primecli-0.10.0/primecli/_wallets.py +46 -0
  4. primecli-0.10.0/primecli/bridge.py +406 -0
  5. {primecli-0.9.0 → primecli-0.10.0}/primecli/degenprime.py +54 -73
  6. {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/PKG-INFO +14 -3
  7. {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/SOURCES.txt +3 -0
  8. {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/entry_points.txt +1 -0
  9. {primecli-0.9.0 → primecli-0.10.0}/pyproject.toml +2 -1
  10. primecli-0.10.0/tests/test_bridge.py +239 -0
  11. {primecli-0.9.0 → primecli-0.10.0}/LICENSE +0 -0
  12. {primecli-0.9.0 → primecli-0.10.0}/primecli/__init__.py +0 -0
  13. {primecli-0.9.0 → primecli-0.10.0}/primecli/arbprime.py +0 -0
  14. {primecli-0.9.0 → primecli-0.10.0}/primecli/deltaprime.py +0 -0
  15. {primecli-0.9.0 → primecli-0.10.0}/primecli/health_monitor.py +0 -0
  16. {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/dependency_links.txt +0 -0
  17. {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/requires.txt +0 -0
  18. {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/top_level.txt +0 -0
  19. {primecli-0.9.0 → primecli-0.10.0}/setup.cfg +0 -0
  20. {primecli-0.9.0 → primecli-0.10.0}/tests/test_aero_rebalance.py +0 -0
  21. {primecli-0.9.0 → primecli-0.10.0}/tests/test_cross_file_identity.py +0 -0
  22. {primecli-0.9.0 → primecli-0.10.0}/tests/test_gas_limit.py +0 -0
  23. {primecli-0.9.0 → primecli-0.10.0}/tests/test_gas_pricing.py +0 -0
  24. {primecli-0.9.0 → primecli-0.10.0}/tests/test_health_meter.py +0 -0
  25. {primecli-0.9.0 → primecli-0.10.0}/tests/test_health_monitor.py +0 -0
  26. {primecli-0.9.0 → primecli-0.10.0}/tests/test_paraswap_validator.py +0 -0
  27. {primecli-0.9.0 → primecli-0.10.0}/tests/test_redstone_encoding.py +0 -0
  28. {primecli-0.9.0 → primecli-0.10.0}/tests/test_to_wei_units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
5
5
  Author: Mnemosyne-quest contributors
6
6
  License: MIT
@@ -37,7 +37,7 @@ Requires-Dist: requests>=2.31
37
37
  [![Python](https://img.shields.io/pypi/pyversions/primecli.svg)](https://pypi.org/project/primecli/)
38
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
39
 
40
- `primecli` installs three console commands, `deltaprime`, `degenprime`, and `arbprime`, that drive the lending and leverage protocols built by the DeltaPrimeLabs team on Avalanche C-chain, Base, and Arbitrum One respectively. All share a per-user smart-account architecture (EIP-2535 diamond) and are operated through the same CLI shape: savings pools, per-user Prime / Degen Accounts, borrow / repay / fund, swaps, debt refinancing, delayed collateral withdrawals. The Avalanche side additionally exposes GMX V2 LP (GM and GM+), TraderJoe V2 LB, sJOE staking, PRIME leverage tiers, and a leveraged-long zap macro. The Arbitrum side carries the same leverage stack adapted to GMX's home chain — 10 GM + 3 GM+ markets, GLV vaults, 11 TraderJoe LB pairs, PRIME tiers, zap (no sJOE). The Base side ships a read-only Aerodrome position inventory; write paths are deferred to v2.
40
+ `primecli` installs four console commands. Three of them — `deltaprime`, `degenprime`, and `arbprime` drive the lending and leverage protocols built by the DeltaPrimeLabs team on Avalanche C-chain, Base, and Arbitrum One respectively. The fourth, `bridge`, moves native or ERC-20 funds between those three chains (via the LiFi aggregator) for any wallet in the shared agent table. All share a per-user smart-account architecture (EIP-2535 diamond) and are operated through the same CLI shape: savings pools, per-user Prime / Degen Accounts, borrow / repay / fund, swaps, debt refinancing, delayed collateral withdrawals. The Avalanche side additionally exposes GMX V2 LP (GM and GM+), TraderJoe V2 LB, sJOE staking, PRIME leverage tiers, and a leveraged-long zap macro. The Arbitrum side carries the same leverage stack adapted to GMX's home chain — 10 GM + 3 GM+ markets, GLV vaults, 11 TraderJoe LB pairs, PRIME tiers, zap (no sJOE). The Base side ships a read-only Aerodrome position inventory; write paths are deferred to v2.
41
41
 
42
42
  Built for agent use:
43
43
 
@@ -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.9.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.0 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
51
51
 
52
52
  > **Breaking change in 0.5.0:** there is no longer a default signing key. Earlier versions silently fell back to a baked-in agent when no key was configured; that fallback has been removed. With no key configured, every command now fails closed with `No signing key found...`. Set a key explicitly (see [Configuration](#configuration)).
53
53
 
@@ -171,6 +171,17 @@ Full per-command + address reference: [docs/arbprime-reference.md](docs/arbprime
171
171
 
172
172
  Note: DeltaPrime has TWO deployments on Arbitrum; `arbprime` targets the live one used by app.deltaprime.io (factory `0xFf5e…c20`, TokenManager `0x0a0D…E255`), with every address verified on-chain against the live SmartLoanDiamondBeacon. The stale artifact deployment (factory `0x97f4…E4E`) only carries ETH+USDC pools — don't use addresses from the repo's `deployments/arbitrum/*TUP.json` artifacts.
173
173
 
174
+ ### `bridge` (cross-chain, Avalanche / Base / Arbitrum)
175
+
176
+ Moves native or ERC-20 funds between chains for any agent in the wallet table, via the LiFi aggregator (li.quest) — same `--as <agent>` interface as the protocol commands.
177
+
178
+ ```bash
179
+ bridge --as <agent> --from <chain> --to <chain> --token <SYM> --amount <decimal> \
180
+ [--to-token <SYM>] [--to-address <addr>] [--slippage <pct>] [--poll] [--execute]
181
+ ```
182
+
183
+ Chains: `avalanche` (43114), `base` (8453), `arbitrum` (42161). `--token` is the symbol on the source chain; `--to-token` defaults to the destination chain's native gas token (a gas top-up, e.g. `AVAX` → `ETH` when bridging to Base) and can be overridden for a same-asset bridge. Dry-run by default; `--execute` broadcasts on the source chain and prints the source tx hash, source explorer URL, and the LiFi status URL (`--poll` then waits for the cross-chain leg to settle). Safety rails: **self-bridge only** (a `--to-address` that differs from the signer is refused), and the bridge is refused if the quote's implied slippage exceeds `--slippage` (default 1.0%). Because LiFi picks the cheapest route per quote, a cross-asset bridge may land at or above the cap depending on the route — re-quote, or raise `--slippage` if the price impact is acceptable. RPCs are overridable via `BRIDGE_<CHAIN>_RPC`; `LIFI_API_KEY` is sent on quote/status calls if set.
184
+
174
185
  ## Configuration
175
186
 
176
187
  | Env var | Default | Purpose |
@@ -6,7 +6,7 @@
6
6
  [![Python](https://img.shields.io/pypi/pyversions/primecli.svg)](https://pypi.org/project/primecli/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
8
 
9
- `primecli` installs three console commands, `deltaprime`, `degenprime`, and `arbprime`, that drive the lending and leverage protocols built by the DeltaPrimeLabs team on Avalanche C-chain, Base, and Arbitrum One respectively. All share a per-user smart-account architecture (EIP-2535 diamond) and are operated through the same CLI shape: savings pools, per-user Prime / Degen Accounts, borrow / repay / fund, swaps, debt refinancing, delayed collateral withdrawals. The Avalanche side additionally exposes GMX V2 LP (GM and GM+), TraderJoe V2 LB, sJOE staking, PRIME leverage tiers, and a leveraged-long zap macro. The Arbitrum side carries the same leverage stack adapted to GMX's home chain — 10 GM + 3 GM+ markets, GLV vaults, 11 TraderJoe LB pairs, PRIME tiers, zap (no sJOE). The Base side ships a read-only Aerodrome position inventory; write paths are deferred to v2.
9
+ `primecli` installs four console commands. Three of them — `deltaprime`, `degenprime`, and `arbprime` drive the lending and leverage protocols built by the DeltaPrimeLabs team on Avalanche C-chain, Base, and Arbitrum One respectively. The fourth, `bridge`, moves native or ERC-20 funds between those three chains (via the LiFi aggregator) for any wallet in the shared agent table. All share a per-user smart-account architecture (EIP-2535 diamond) and are operated through the same CLI shape: savings pools, per-user Prime / Degen Accounts, borrow / repay / fund, swaps, debt refinancing, delayed collateral withdrawals. The Avalanche side additionally exposes GMX V2 LP (GM and GM+), TraderJoe V2 LB, sJOE staking, PRIME leverage tiers, and a leveraged-long zap macro. The Arbitrum side carries the same leverage stack adapted to GMX's home chain — 10 GM + 3 GM+ markets, GLV vaults, 11 TraderJoe LB pairs, PRIME tiers, zap (no sJOE). The Base side ships a read-only Aerodrome position inventory; write paths are deferred to v2.
10
10
 
11
11
  Built for agent use:
12
12
 
@@ -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.9.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.0 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
20
20
 
21
21
  > **Breaking change in 0.5.0:** there is no longer a default signing key. Earlier versions silently fell back to a baked-in agent when no key was configured; that fallback has been removed. With no key configured, every command now fails closed with `No signing key found...`. Set a key explicitly (see [Configuration](#configuration)).
22
22
 
@@ -140,6 +140,17 @@ Full per-command + address reference: [docs/arbprime-reference.md](docs/arbprime
140
140
 
141
141
  Note: DeltaPrime has TWO deployments on Arbitrum; `arbprime` targets the live one used by app.deltaprime.io (factory `0xFf5e…c20`, TokenManager `0x0a0D…E255`), with every address verified on-chain against the live SmartLoanDiamondBeacon. The stale artifact deployment (factory `0x97f4…E4E`) only carries ETH+USDC pools — don't use addresses from the repo's `deployments/arbitrum/*TUP.json` artifacts.
142
142
 
143
+ ### `bridge` (cross-chain, Avalanche / Base / Arbitrum)
144
+
145
+ Moves native or ERC-20 funds between chains for any agent in the wallet table, via the LiFi aggregator (li.quest) — same `--as <agent>` interface as the protocol commands.
146
+
147
+ ```bash
148
+ bridge --as <agent> --from <chain> --to <chain> --token <SYM> --amount <decimal> \
149
+ [--to-token <SYM>] [--to-address <addr>] [--slippage <pct>] [--poll] [--execute]
150
+ ```
151
+
152
+ Chains: `avalanche` (43114), `base` (8453), `arbitrum` (42161). `--token` is the symbol on the source chain; `--to-token` defaults to the destination chain's native gas token (a gas top-up, e.g. `AVAX` → `ETH` when bridging to Base) and can be overridden for a same-asset bridge. Dry-run by default; `--execute` broadcasts on the source chain and prints the source tx hash, source explorer URL, and the LiFi status URL (`--poll` then waits for the cross-chain leg to settle). Safety rails: **self-bridge only** (a `--to-address` that differs from the signer is refused), and the bridge is refused if the quote's implied slippage exceeds `--slippage` (default 1.0%). Because LiFi picks the cheapest route per quote, a cross-asset bridge may land at or above the cap depending on the route — re-quote, or raise `--slippage` if the price impact is acceptable. RPCs are overridable via `BRIDGE_<CHAIN>_RPC`; `LIFI_API_KEY` is sent on quote/status calls if set.
153
+
143
154
  ## Configuration
144
155
 
145
156
  | Env var | Default | Purpose |
@@ -0,0 +1,46 @@
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
+ # Named-wallet table shared with deltaprime/arbprime/degenprime/bridge. Allows
15
+ # selecting a signer via `--as <agent>` (or the per-tool *_AGENT env vars) rather
16
+ # than passing a raw key through the environment.
17
+ AGENTS = {
18
+ "parakletos": ("/root/.openclaw/.env", "PARAKLETOS_EVM_PRIVATE_KEY"),
19
+ "paraklaudios": ("/root/paraklaudios/.credentials.env", "PARAKLAUDIOS_EVM_PRIVATE_KEY"),
20
+ "core1": ("/root/.openclaw/.env", "BRUNO_CORE1_PRIVATE_KEY"),
21
+ }
22
+
23
+
24
+ def _read_env_var(path, var):
25
+ """Return the value of `var` from a KEY=VALUE env file, or None if absent."""
26
+ try:
27
+ for line in Path(path).read_text().splitlines():
28
+ s = line.strip()
29
+ if s.startswith(var + "="):
30
+ return s.split("=", 1)[1].strip().strip('"').strip("'")
31
+ except FileNotFoundError:
32
+ return None
33
+ return None
34
+
35
+
36
+ def _agent_key(agent):
37
+ if agent not in AGENTS:
38
+ raise RuntimeError(
39
+ f"Unknown agent '{agent}'. Known agents: {', '.join(AGENTS)}. "
40
+ f"Or set DEGENPRIME_PRIVATE_KEY, or DEGENPRIME_KEY_FILE."
41
+ )
42
+ path, var = AGENTS[agent]
43
+ key = _read_env_var(path, var)
44
+ if not key:
45
+ raise RuntimeError(f"{var} not found in {path} (agent '{agent}').")
46
+ return key
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env python3
2
+ """bridge - cross-chain bridge for the primecli wallets (Avalanche / Base / Arbitrum).
3
+
4
+ Move native or ERC-20 funds between chains for any wallet primecli already knows
5
+ (parakletos, paraklaudios, core1) via the same `--as <agent>` interface the
6
+ protocol commands use. Routing goes through the LiFi aggregator (li.quest) — the
7
+ same endpoint and tx shape as a same-chain swap, just with toChain != fromChain.
8
+
9
+ bridge --as <agent> --from <chain> --to <chain> --token <SYM> --amount <decimal>
10
+ [--to-address <addr>] [--to-token <SYM>] [--slippage <pct>] [--execute]
11
+
12
+ Chains: avalanche (43114) | base (8453) | arbitrum (42161).
13
+
14
+ SAFETY
15
+ * Dry-run by DEFAULT. Only --execute broadcasts (exactly like the protocol commands).
16
+ * Self-bridge only (v1): the destination is the SIGNER'S OWN address. Passing a
17
+ --to-address that differs from the signer is REFUSED.
18
+ * Destination token defaults to the destination chain's native gas token
19
+ (gas-top-up default, e.g. AVAX -> ETH when bridging to Base). Override with
20
+ --to-token if you want the same asset on the other side.
21
+ * Slippage cap default 1.0%. If the quote's toAmountMin implies worse than the
22
+ cap, the bridge is REFUSED.
23
+
24
+ Routing keys (set if LiFi rate-limits you):
25
+ LIFI_API_KEY -> sent as x-lifi-api-key header on quote/status calls.
26
+ """
27
+
28
+ import argparse
29
+ import os
30
+ import sys
31
+ import time
32
+ from decimal import Decimal
33
+
34
+ import requests
35
+ from eth_account import Account
36
+ from web3 import Web3
37
+ from web3.middleware import ExtraDataToPOAMiddleware
38
+
39
+ from primecli._wallets import AGENTS, _agent_key
40
+
41
+ LIFI_QUOTE_URL = "https://li.quest/v1/quote"
42
+ LIFI_STATUS_URL = "https://li.quest/v1/status"
43
+
44
+ # Native-token sentinel LiFi expects for "the chain's gas token".
45
+ NATIVE_SENTINEL = "0x0000000000000000000000000000000000000000"
46
+
47
+ ERC20_ABI = [
48
+ {"constant": True, "inputs": [{"name": "_owner", "type": "address"}, {"name": "_spender", "type": "address"}], "name": "allowance", "outputs": [{"name": "", "type": "uint256"}], "type": "function"},
49
+ {"constant": False, "inputs": [{"name": "_spender", "type": "address"}, {"name": "_value", "type": "uint256"}], "name": "approve", "outputs": [{"name": "", "type": "bool"}], "type": "function"},
50
+ ]
51
+
52
+ # Chain config — the single source of truth for this command. Mirrors the shape
53
+ # of paraklaudios/config/wallet.json but kept inline (bridge spans all three
54
+ # chains, where each protocol module owns just one). RPCs are overridable via
55
+ # BRIDGE_<CHAIN>_RPC so a flaky public endpoint can be swapped without an edit.
56
+ CHAINS = {
57
+ "avalanche": {
58
+ "chain_id": 43114,
59
+ "rpc": os.environ.get("BRIDGE_AVALANCHE_RPC", "https://api.avax.network/ext/bc/C/rpc"),
60
+ "explorer": "https://snowtrace.io",
61
+ "poa": True,
62
+ "native": "AVAX",
63
+ "tokens": {
64
+ "AVAX": {"address": None, "decimals": 18},
65
+ "WAVAX": {"address": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", "decimals": 18},
66
+ "USDC": {"address": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "decimals": 6},
67
+ "USDT": {"address": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", "decimals": 6},
68
+ },
69
+ },
70
+ "base": {
71
+ "chain_id": 8453,
72
+ "rpc": os.environ.get("BRIDGE_BASE_RPC", "https://mainnet.base.org"),
73
+ "explorer": "https://basescan.org",
74
+ "poa": False,
75
+ "native": "ETH",
76
+ "tokens": {
77
+ "ETH": {"address": None, "decimals": 18},
78
+ "WETH": {"address": "0x4200000000000000000000000000000000000006", "decimals": 18},
79
+ "USDC": {"address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "decimals": 6},
80
+ "USDbC": {"address": "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA", "decimals": 6},
81
+ },
82
+ },
83
+ "arbitrum": {
84
+ "chain_id": 42161,
85
+ "rpc": os.environ.get("BRIDGE_ARBITRUM_RPC", "https://arb1.arbitrum.io/rpc"),
86
+ "explorer": "https://arbiscan.io",
87
+ "poa": False,
88
+ "native": "ETH",
89
+ "tokens": {
90
+ "ETH": {"address": None, "decimals": 18},
91
+ "WETH": {"address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "decimals": 18},
92
+ "USDC": {"address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "decimals": 6},
93
+ "USDT": {"address": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", "decimals": 6},
94
+ "ARB": {"address": "0x912CE59144191C1204E64559FE8253a0e49E6548", "decimals": 18},
95
+ },
96
+ },
97
+ }
98
+
99
+
100
+ def _hexint(v, default=0):
101
+ """LiFi returns ints sometimes as 0x-hex strings, sometimes as decimals.
102
+ Parse robustly (mirrors walletctl's tolerance)."""
103
+ if v is None:
104
+ return default
105
+ if isinstance(v, int):
106
+ return v
107
+ s = str(v)
108
+ if s.startswith("0x") or s.startswith("0X"):
109
+ return int(s, 16)
110
+ return int(s)
111
+
112
+
113
+ def resolve_token(chain, symbol):
114
+ """Return {symbol, address|None, decimals, native, lifi} for a symbol on a chain."""
115
+ cfg = CHAINS[chain]
116
+ sym = symbol.upper()
117
+ if sym not in cfg["tokens"]:
118
+ known = ", ".join(cfg["tokens"])
119
+ raise SystemExit(
120
+ f"Unknown token '{symbol}' on {chain}. Known: {known}. "
121
+ f"(Add it to CHAINS in bridge.py if you need another.)"
122
+ )
123
+ t = cfg["tokens"][sym]
124
+ native = t["address"] is None
125
+ addr = None if native else Web3.to_checksum_address(t["address"])
126
+ return {
127
+ "symbol": sym,
128
+ "address": addr,
129
+ "decimals": t["decimals"],
130
+ "native": native,
131
+ "lifi": NATIVE_SENTINEL if native else addr,
132
+ }
133
+
134
+
135
+ def w3_for(chain):
136
+ cfg = CHAINS[chain]
137
+ w3 = Web3(Web3.HTTPProvider(cfg["rpc"], request_kwargs={"timeout": 20}))
138
+ if cfg["poa"]:
139
+ w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
140
+ return w3
141
+
142
+
143
+ def _lifi_headers():
144
+ key = os.environ.get("LIFI_API_KEY")
145
+ return {"x-lifi-api-key": key} if key else {}
146
+
147
+
148
+ def lifi_quote(from_chain, to_chain, from_tok, to_tok, raw_amount, address, slippage):
149
+ params = {
150
+ "fromChain": CHAINS[from_chain]["chain_id"],
151
+ "toChain": CHAINS[to_chain]["chain_id"],
152
+ "fromToken": from_tok["lifi"],
153
+ "toToken": to_tok["lifi"],
154
+ "fromAmount": str(raw_amount),
155
+ "fromAddress": address,
156
+ "toAddress": address, # self-bridge: destination is the signer's own EOA
157
+ "slippage": slippage,
158
+ }
159
+ r = requests.get(LIFI_QUOTE_URL, params=params, headers=_lifi_headers(), timeout=30)
160
+ if r.status_code != 200:
161
+ raise SystemExit(f"LiFi quote failed ({r.status_code}): {r.text[:400]}")
162
+ return r.json()
163
+
164
+
165
+ def build_preview(quote, from_chain, to_chain, from_tok, to_tok, amount, slippage):
166
+ """Turn a LiFi quote into a structured preview dict (no I/O, unit-testable)."""
167
+ est = quote["estimate"]
168
+ to_amount = Decimal(str(est["toAmount"])) / (Decimal(10) ** to_tok["decimals"])
169
+ to_amount_min = Decimal(str(est["toAmountMin"])) / (Decimal(10) ** to_tok["decimals"])
170
+ expected = to_amount if to_amount > 0 else Decimal(1)
171
+ implied_slippage = float((to_amount - to_amount_min) / expected) if to_amount > 0 else 0.0
172
+ fee_costs = est.get("feeCosts") or []
173
+ gas_costs = est.get("gasCosts") or []
174
+ fee_usd = sum(float(f.get("amountUSD", 0) or 0) for f in fee_costs)
175
+ gas_usd = sum(float(g.get("amountUSD", 0) or 0) for g in gas_costs)
176
+ tool = quote.get("tool") or quote.get("toolDetails", {}).get("name") or est.get("tool") or "?"
177
+ return {
178
+ "from_chain": from_chain,
179
+ "to_chain": to_chain,
180
+ "from_token": from_tok["symbol"],
181
+ "to_token": to_tok["symbol"],
182
+ "amount": amount,
183
+ "to_amount": to_amount,
184
+ "to_amount_min": to_amount_min,
185
+ "implied_slippage": implied_slippage,
186
+ "slippage_cap": slippage,
187
+ "fee_usd": fee_usd,
188
+ "gas_usd": gas_usd,
189
+ "tool": tool,
190
+ }
191
+
192
+
193
+ def print_preview(p, execute):
194
+ mode = "EXECUTE" if execute else "DRY-RUN"
195
+ print(f"=== BRIDGE ({mode}) ===")
196
+ print(f" from: {p['amount']} {p['from_token']} on {p['from_chain']}")
197
+ print(f" to (est): {p['to_amount']} {p['to_token']} on {p['to_chain']}")
198
+ print(f" to (min): {p['to_amount_min']} {p['to_token']} "
199
+ f"(implied slippage {p['implied_slippage']*100:.3f}%, cap {p['slippage_cap']*100:.2f}%)")
200
+ print(f" route tool: {p['tool']}")
201
+ print(f" protocol fee: ${p['fee_usd']:.4f}")
202
+ print(f" source gas: ${p['gas_usd']:.4f}")
203
+
204
+
205
+ def enforce_slippage(preview):
206
+ """Refuse if the quote's implied slippage is worse than the cap."""
207
+ if preview["implied_slippage"] > preview["slippage_cap"] + 1e-9:
208
+ raise SystemExit(
209
+ f"REFUSED: quote implies {preview['implied_slippage']*100:.3f}% slippage, "
210
+ f"over the {preview['slippage_cap']*100:.2f}% cap. "
211
+ f"Raise --slippage only if you understand the price impact."
212
+ )
213
+
214
+
215
+ def _send_tx(w3, acct, tx_req, chain_id):
216
+ """Sign and broadcast a LiFi transactionRequest on the source chain."""
217
+ tx = {
218
+ "chainId": chain_id,
219
+ "from": acct.address,
220
+ "to": Web3.to_checksum_address(tx_req["to"]),
221
+ "data": tx_req["data"],
222
+ "value": _hexint(tx_req.get("value", 0)),
223
+ "nonce": w3.eth.get_transaction_count(acct.address),
224
+ "gas": _hexint(tx_req["gasLimit"], 0),
225
+ }
226
+ if "maxFeePerGas" in tx_req and "maxPriorityFeePerGas" in tx_req:
227
+ tx["maxFeePerGas"] = _hexint(tx_req["maxFeePerGas"])
228
+ tx["maxPriorityFeePerGas"] = _hexint(tx_req["maxPriorityFeePerGas"])
229
+ tx["type"] = 2
230
+ elif "gasPrice" in tx_req:
231
+ tx["gasPrice"] = _hexint(tx_req["gasPrice"])
232
+ signed = acct.sign_transaction(tx)
233
+ return w3.eth.send_raw_transaction(signed.raw_transaction)
234
+
235
+
236
+ def poll_status(tx_hash, from_chain, to_chain, timeout=300, interval=15):
237
+ """Poll LiFi /v1/status until DONE/FAILED or timeout. Best-effort."""
238
+ params = {
239
+ "txHash": tx_hash,
240
+ "fromChain": CHAINS[from_chain]["chain_id"],
241
+ "toChain": CHAINS[to_chain]["chain_id"],
242
+ }
243
+ deadline = time.time() + timeout
244
+ last = None
245
+ while time.time() < deadline:
246
+ try:
247
+ r = requests.get(LIFI_STATUS_URL, params=params, headers=_lifi_headers(), timeout=20)
248
+ if r.status_code == 200:
249
+ data = r.json()
250
+ status = data.get("status")
251
+ substatus = data.get("substatus")
252
+ if status != last:
253
+ print(f" status: {status}" + (f" ({substatus})" if substatus else ""))
254
+ last = status
255
+ if status in ("DONE", "FAILED", "REFUNDED"):
256
+ return data
257
+ except requests.RequestException:
258
+ pass
259
+ time.sleep(interval)
260
+ print(" status: still pending after timeout — check the LiFi status URL above.")
261
+ return None
262
+
263
+
264
+ def run(args):
265
+ if args.from_chain == args.to_chain:
266
+ raise SystemExit(
267
+ f"--from and --to are both '{args.from_chain}'. For same-chain swaps use the "
268
+ f"swap command, not bridge."
269
+ )
270
+ if args.agent not in AGENTS:
271
+ raise SystemExit(f"Unknown agent '{args.agent}'. Known: {', '.join(AGENTS)}.")
272
+
273
+ acct = Account.from_key(_agent_key(args.agent))
274
+
275
+ # Self-bridge enforcement: destination must be the signer's own address.
276
+ if args.to_address is not None:
277
+ if Web3.to_checksum_address(args.to_address) != acct.address:
278
+ raise SystemExit(
279
+ f"REFUSED: --to-address {args.to_address} differs from the signer "
280
+ f"({acct.address}). v1 is self-bridge only — funds can only go to the "
281
+ f"signer's own EOA on the destination chain."
282
+ )
283
+
284
+ from_tok = resolve_token(args.from_chain, args.token)
285
+ to_sym = args.to_token if args.to_token else CHAINS[args.to_chain]["native"]
286
+ to_tok = resolve_token(args.to_chain, to_sym)
287
+
288
+ slippage = args.slippage / 100.0 # CLI is a percent; LiFi wants a fraction
289
+ amount = Decimal(str(args.amount))
290
+ raw_amount = int(amount * (Decimal(10) ** from_tok["decimals"]))
291
+
292
+ print(f"signer: {acct.address} (--as {args.agent})")
293
+ quote = lifi_quote(
294
+ args.from_chain, args.to_chain, from_tok, to_tok, raw_amount, acct.address, slippage
295
+ )
296
+ preview = build_preview(
297
+ quote, args.from_chain, args.to_chain, from_tok, to_tok, amount, slippage
298
+ )
299
+ print_preview(preview, args.execute)
300
+ enforce_slippage(preview)
301
+
302
+ if not args.execute:
303
+ print("\nDry-run only. Re-run with --execute to broadcast on the source chain.")
304
+ return
305
+
306
+ w3 = w3_for(args.from_chain)
307
+ chain_id = CHAINS[args.from_chain]["chain_id"]
308
+ tx_req = quote["transactionRequest"]
309
+ spender = Web3.to_checksum_address(tx_req["to"])
310
+
311
+ # ERC-20 source token needs an allowance to the LiFi router/diamond first.
312
+ if not from_tok["native"]:
313
+ token = w3.eth.contract(address=from_tok["address"], abi=ERC20_ABI)
314
+ allowance = token.functions.allowance(acct.address, spender).call()
315
+ if allowance < raw_amount:
316
+ print(f"\nApproving {from_tok['symbol']} for {spender} ...")
317
+ fees = w3.eth.fee_history(1, "latest")
318
+ base_fee = fees["baseFeePerGas"][-1]
319
+ priority = w3.to_wei(2, "gwei") if args.from_chain == "avalanche" else w3.to_wei(1.5, "gwei")
320
+ approve_tx = token.functions.approve(spender, raw_amount).build_transaction({
321
+ "chainId": chain_id,
322
+ "from": acct.address,
323
+ "nonce": w3.eth.get_transaction_count(acct.address),
324
+ "maxFeePerGas": base_fee * 2 + priority,
325
+ "maxPriorityFeePerGas": priority,
326
+ "type": 2,
327
+ })
328
+ signed = acct.sign_transaction(approve_tx)
329
+ ah = w3.eth.send_raw_transaction(signed.raw_transaction)
330
+ print(f" approve tx: {ah.hex()}")
331
+ w3.eth.wait_for_transaction_receipt(ah, timeout=180)
332
+
333
+ tx_hash = _send_tx(w3, acct, tx_req, chain_id)
334
+ hx = tx_hash.hex()
335
+ if not hx.startswith("0x"):
336
+ hx = "0x" + hx
337
+ explorer = CHAINS[args.from_chain]["explorer"]
338
+ status_url = (
339
+ f"{LIFI_STATUS_URL}?txHash={hx}"
340
+ f"&fromChain={CHAINS[args.from_chain]['chain_id']}"
341
+ f"&toChain={CHAINS[args.to_chain]['chain_id']}"
342
+ )
343
+ print(f"\nBroadcast on {args.from_chain}: {hx}")
344
+ print(f"Source explorer: {explorer}/tx/{hx}")
345
+ print(f"LiFi status: {status_url}")
346
+
347
+ if args.poll:
348
+ print("\nPolling LiFi for cross-chain completion ...")
349
+ final = poll_status(hx, args.from_chain, args.to_chain)
350
+ if final:
351
+ print(f"Final status: {final.get('status')} "
352
+ f"({final.get('substatus', '')})".rstrip(" ()"))
353
+
354
+
355
+ def build_parser():
356
+ p = argparse.ArgumentParser(
357
+ prog="bridge",
358
+ description="Cross-chain bridge for primecli wallets (Avalanche / Base / Arbitrum) via LiFi.",
359
+ )
360
+ p.add_argument("--as", dest="agent", required=True,
361
+ help="Wallet to sign as: " + ", ".join(AGENTS))
362
+ p.add_argument("--from", dest="from_chain", required=True, choices=list(CHAINS),
363
+ help="Source chain.")
364
+ p.add_argument("--to", dest="to_chain", required=True, choices=list(CHAINS),
365
+ help="Destination chain.")
366
+ p.add_argument("--token", required=True,
367
+ help="Token symbol on the SOURCE chain (e.g. AVAX, ETH, USDC).")
368
+ p.add_argument("--amount", required=True,
369
+ help="Amount of --token to bridge (decimal).")
370
+ p.add_argument("--to-token", default=None,
371
+ help="Token to receive on the destination chain. "
372
+ "Default: the destination chain's native gas token (gas top-up).")
373
+ p.add_argument("--to-address", default=None,
374
+ help="Destination address. Must equal the signer (self-bridge only); "
375
+ "omit to default to the signer's own EOA.")
376
+ p.add_argument("--slippage", type=float, default=1.0,
377
+ help="Max slippage percent (default 1.0). Quotes worse than this are refused.")
378
+ p.add_argument("--poll", action="store_true",
379
+ help="After --execute, poll LiFi status until the bridge completes or times out.")
380
+ p.add_argument("--execute", action="store_true",
381
+ help="Broadcast the bridge tx. Without this flag the command is a dry-run.")
382
+ # Accepted for parity with the other primecli commands; the actual suppression
383
+ # is handled by check_version() reading sys.argv directly.
384
+ p.add_argument("--no-version-check", action="store_true", help=argparse.SUPPRESS)
385
+ return p
386
+
387
+
388
+ def main(argv=None):
389
+ try:
390
+ from primecli import check_version
391
+ except ImportError:
392
+ def check_version(*a, **kw):
393
+ pass
394
+ check_version()
395
+ args = build_parser().parse_args(argv)
396
+ # argparse stores --amount as a string; validate it is a positive number early.
397
+ try:
398
+ if Decimal(str(args.amount)) <= 0:
399
+ raise SystemExit("--amount must be positive.")
400
+ except (ArithmeticError, ValueError):
401
+ raise SystemExit(f"--amount '{args.amount}' is not a valid number.")
402
+ run(args)
403
+
404
+
405
+ if __name__ == "__main__":
406
+ main()
@@ -180,43 +180,17 @@ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
180
180
  # read lazily so read-only commands that don't sign never need a key at all.
181
181
  _CLI_KEY = None # set by the --key CLI flag in main()
182
182
 
183
- # Named-wallet table shared with deltaprime/arbprime. Allows running via
183
+ # Named-wallet table shared with deltaprime/arbprime/bridge. Allows running via
184
184
  # DEGENPRIME_AGENT=parakletos (or the fallback DELTAPRIME_AGENT) which is
185
185
  # cleaner than passing raw keys through environment variables.
186
186
  # Agent resolution also supports --as <agent> CLI flag.
187
- AGENTS = {
188
- "parakletos": ("/root/.openclaw/.env", "PARAKLETOS_EVM_PRIVATE_KEY"),
189
- "paraklaudios": ("/root/paraklaudios/.credentials.env", "PARAKLAUDIOS_EVM_PRIVATE_KEY"),
190
- "core1": ("/root/.openclaw/.env", "BRUNO_CORE1_PRIVATE_KEY"),
191
- }
187
+ # The map and its readers live in primecli._wallets as the single source of
188
+ # truth; re-exported here so existing references (degenprime.AGENTS, etc.) and
189
+ # resolve_private_key() below keep working unchanged.
190
+ from primecli._wallets import AGENTS, _read_env_var, _agent_key # noqa: E402
192
191
  _SELECTED_AGENT = None # set by the --as CLI flag in main()
193
192
 
194
193
 
195
- def _read_env_var(path, var):
196
- """Return the value of `var` from a KEY=VALUE env file, or None if absent."""
197
- try:
198
- for line in Path(path).read_text().splitlines():
199
- s = line.strip()
200
- if s.startswith(var + "="):
201
- return s.split("=", 1)[1].strip().strip('"').strip("'")
202
- except FileNotFoundError:
203
- return None
204
- return None
205
-
206
-
207
- def _agent_key(agent):
208
- if agent not in AGENTS:
209
- raise RuntimeError(
210
- f"Unknown agent '{agent}'. Known agents: {', '.join(AGENTS)}. "
211
- f"Or set DEGENPRIME_PRIVATE_KEY, or DEGENPRIME_KEY_FILE."
212
- )
213
- path, var = AGENTS[agent]
214
- key = _read_env_var(path, var)
215
- if not key:
216
- raise RuntimeError(f"{var} not found in {path} (agent '{agent}').")
217
- return key
218
-
219
-
220
194
  # Core protocol addresses (verified on Base 2026-05-29).
221
195
  FACTORY_PROXY = "0x5A6a0e2702cF4603a098C3Df01f3F0DF56115456" # SmartLoansFactory TUP
222
196
  # Diamond beacon. Every Degen Account is a per-user proxy that delegates here, so the
@@ -4353,8 +4327,10 @@ def _aero_rebalance_events(account: str, from_block=None, to_block="latest"):
4353
4327
  if from_block is None:
4354
4328
  # The shared emitter went live with the 2026-06-16 migration; a recent window
4355
4329
  # covers all post-migration history. Public Base RPCs cap getLogs at 50k blocks,
4356
- # so default to ~180k blocks back (≈4 days at 2s/block) and page under the cap.
4357
- from_block = max(0, latest - 180_000)
4330
+ # so default to ~90k blocks back (≈50h at 2s/block, covering the 48h KO window)
4331
+ # and page under the cap. All four event types are fetched in ONE getLogs per
4332
+ # chunk (topic0 OR-list) below, so this stays fast enough for a per-tick call.
4333
+ from_block = max(0, latest - 90_000)
4358
4334
  # Non-indexed arg types per event (indexed userContract/user/tokenId/executor live
4359
4335
  # in the topics, not the data blob).
4360
4336
  nonindexed = {
@@ -4364,47 +4340,52 @@ def _aero_rebalance_events(account: str, from_block=None, to_block="latest"):
4364
4340
  "RebalanceExecuted": ["uint160", "uint160", "uint256", "uint256"],
4365
4341
  }
4366
4342
  CHUNK = 45_000 # under the public-RPC 50k-block getLogs cap
4343
+ # Reverse map topic0 -> event name so a SINGLE getLogs (topic0 as an OR-list) returns
4344
+ # all four event types per chunk, then is demuxed here. One scan per event type
4345
+ # (the previous approach) meant 4x the getLogs round-trips, which timed out the
4346
+ # per-tick range-monitor call.
4347
+ t0_to_name = {t.lower().removeprefix("0x"): n for n, t in REBALANCE_TOPIC0.items()}
4348
+ all_topic0 = list(REBALANCE_TOPIC0.values())
4367
4349
  out = []
4368
- for name, topic0 in REBALANCE_TOPIC0.items():
4369
- logs = []
4370
- start = from_block
4371
- try:
4372
- while start <= to_block:
4373
- end = min(start + CHUNK - 1, to_block)
4374
- logs.extend(w3.eth.get_logs({"address": emitter, "fromBlock": start,
4375
- "toBlock": end, "topics": [topic0, acct_topic]}))
4376
- start = end + 1
4377
- except Exception as e:
4378
- print(f" (getLogs failed for {name}: {type(e).__name__}: {str(e)[:120]})")
4379
- continue
4380
- for lg in logs:
4381
- ev = {"event": name, "block": lg["blockNumber"],
4382
- "logIndex": lg["logIndex"], "tx": lg["transactionHash"].hex()}
4383
- # topic2 is tokenId (Created/Updated/Canceled) or executor (Executed);
4384
- # for Executed the OLD tokenId is topic3.
4385
- topics = lg["topics"]
4386
- decoded = w3.codec.decode(nonindexed[name], bytes(lg["data"]))
4387
- if name == "RebalanceExecuted":
4388
- ev["executor"] = "0x" + topics[2].hex()[-40:]
4389
- ev["tokenId"] = int(topics[3].hex(), 16)
4390
- ev["oldRefSqrtPriceX96"] = decoded[0]
4391
- ev["currentSqrtPriceX96"] = decoded[1]
4392
- ev["newTokenId"] = decoded[2]
4393
- ev["timestamp"] = decoded[3]
4394
- else:
4395
- ev["tokenId"] = int(topics[3].hex(), 16) if len(topics) > 3 else None
4396
- if name == "RebalanceOrderCreated":
4397
- ev["rangeBps"] = [decoded[0], decoded[1]]
4398
- ev["triggerBps"] = [decoded[2], decoded[3]]
4399
- ev["executionFeeWeth"] = decoded[4]
4400
- ev["timestamp"] = decoded[5]
4401
- elif name == "RebalanceOrderUpdated":
4402
- ev["rangeBps"] = [decoded[0], decoded[1]]
4403
- ev["triggerBps"] = [decoded[2], decoded[3]]
4404
- ev["timestamp"] = decoded[4]
4405
- elif name == "RebalanceOrderCanceled":
4406
- ev["timestamp"] = decoded[0]
4407
- out.append(ev)
4350
+ start = from_block
4351
+ try:
4352
+ while start <= to_block:
4353
+ end = min(start + CHUNK - 1, to_block)
4354
+ chunk_logs = w3.eth.get_logs({"address": emitter, "fromBlock": start,
4355
+ "toBlock": end, "topics": [all_topic0, acct_topic]})
4356
+ for lg in chunk_logs:
4357
+ name = t0_to_name.get(lg["topics"][0].hex().lower().removeprefix("0x"))
4358
+ if name is None:
4359
+ continue
4360
+ ev = {"event": name, "block": lg["blockNumber"],
4361
+ "logIndex": lg["logIndex"], "tx": lg["transactionHash"].hex()}
4362
+ # topic2 is executor (Executed); topic3 is the tokenId (all four events).
4363
+ topics = lg["topics"]
4364
+ decoded = w3.codec.decode(nonindexed[name], bytes(lg["data"]))
4365
+ if name == "RebalanceExecuted":
4366
+ ev["executor"] = "0x" + topics[2].hex()[-40:]
4367
+ ev["tokenId"] = int(topics[3].hex(), 16)
4368
+ ev["oldRefSqrtPriceX96"] = decoded[0]
4369
+ ev["currentSqrtPriceX96"] = decoded[1]
4370
+ ev["newTokenId"] = decoded[2]
4371
+ ev["timestamp"] = decoded[3]
4372
+ else:
4373
+ ev["tokenId"] = int(topics[3].hex(), 16) if len(topics) > 3 else None
4374
+ if name == "RebalanceOrderCreated":
4375
+ ev["rangeBps"] = [decoded[0], decoded[1]]
4376
+ ev["triggerBps"] = [decoded[2], decoded[3]]
4377
+ ev["executionFeeWeth"] = decoded[4]
4378
+ ev["timestamp"] = decoded[5]
4379
+ elif name == "RebalanceOrderUpdated":
4380
+ ev["rangeBps"] = [decoded[0], decoded[1]]
4381
+ ev["triggerBps"] = [decoded[2], decoded[3]]
4382
+ ev["timestamp"] = decoded[4]
4383
+ elif name == "RebalanceOrderCanceled":
4384
+ ev["timestamp"] = decoded[0]
4385
+ out.append(ev)
4386
+ start = end + 1
4387
+ except Exception as e:
4388
+ print(f" (getLogs failed: {type(e).__name__}: {str(e)[:120]})")
4408
4389
  out.sort(key=lambda e: (e["block"], e["logIndex"]))
4409
4390
  return out
4410
4391
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: primecli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
5
5
  Author: Mnemosyne-quest contributors
6
6
  License: MIT
@@ -37,7 +37,7 @@ Requires-Dist: requests>=2.31
37
37
  [![Python](https://img.shields.io/pypi/pyversions/primecli.svg)](https://pypi.org/project/primecli/)
38
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
39
 
40
- `primecli` installs three console commands, `deltaprime`, `degenprime`, and `arbprime`, that drive the lending and leverage protocols built by the DeltaPrimeLabs team on Avalanche C-chain, Base, and Arbitrum One respectively. All share a per-user smart-account architecture (EIP-2535 diamond) and are operated through the same CLI shape: savings pools, per-user Prime / Degen Accounts, borrow / repay / fund, swaps, debt refinancing, delayed collateral withdrawals. The Avalanche side additionally exposes GMX V2 LP (GM and GM+), TraderJoe V2 LB, sJOE staking, PRIME leverage tiers, and a leveraged-long zap macro. The Arbitrum side carries the same leverage stack adapted to GMX's home chain — 10 GM + 3 GM+ markets, GLV vaults, 11 TraderJoe LB pairs, PRIME tiers, zap (no sJOE). The Base side ships a read-only Aerodrome position inventory; write paths are deferred to v2.
40
+ `primecli` installs four console commands. Three of them — `deltaprime`, `degenprime`, and `arbprime` drive the lending and leverage protocols built by the DeltaPrimeLabs team on Avalanche C-chain, Base, and Arbitrum One respectively. The fourth, `bridge`, moves native or ERC-20 funds between those three chains (via the LiFi aggregator) for any wallet in the shared agent table. All share a per-user smart-account architecture (EIP-2535 diamond) and are operated through the same CLI shape: savings pools, per-user Prime / Degen Accounts, borrow / repay / fund, swaps, debt refinancing, delayed collateral withdrawals. The Avalanche side additionally exposes GMX V2 LP (GM and GM+), TraderJoe V2 LB, sJOE staking, PRIME leverage tiers, and a leveraged-long zap macro. The Arbitrum side carries the same leverage stack adapted to GMX's home chain — 10 GM + 3 GM+ markets, GLV vaults, 11 TraderJoe LB pairs, PRIME tiers, zap (no sJOE). The Base side ships a read-only Aerodrome position inventory; write paths are deferred to v2.
41
41
 
42
42
  Built for agent use:
43
43
 
@@ -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.9.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.0 The 0.x line is pre-1.0, so breaking changes are possible. See [Releases](https://github.com/Mnemosyne-quest/primecli/releases).
51
51
 
52
52
  > **Breaking change in 0.5.0:** there is no longer a default signing key. Earlier versions silently fell back to a baked-in agent when no key was configured; that fallback has been removed. With no key configured, every command now fails closed with `No signing key found...`. Set a key explicitly (see [Configuration](#configuration)).
53
53
 
@@ -171,6 +171,17 @@ Full per-command + address reference: [docs/arbprime-reference.md](docs/arbprime
171
171
 
172
172
  Note: DeltaPrime has TWO deployments on Arbitrum; `arbprime` targets the live one used by app.deltaprime.io (factory `0xFf5e…c20`, TokenManager `0x0a0D…E255`), with every address verified on-chain against the live SmartLoanDiamondBeacon. The stale artifact deployment (factory `0x97f4…E4E`) only carries ETH+USDC pools — don't use addresses from the repo's `deployments/arbitrum/*TUP.json` artifacts.
173
173
 
174
+ ### `bridge` (cross-chain, Avalanche / Base / Arbitrum)
175
+
176
+ Moves native or ERC-20 funds between chains for any agent in the wallet table, via the LiFi aggregator (li.quest) — same `--as <agent>` interface as the protocol commands.
177
+
178
+ ```bash
179
+ bridge --as <agent> --from <chain> --to <chain> --token <SYM> --amount <decimal> \
180
+ [--to-token <SYM>] [--to-address <addr>] [--slippage <pct>] [--poll] [--execute]
181
+ ```
182
+
183
+ Chains: `avalanche` (43114), `base` (8453), `arbitrum` (42161). `--token` is the symbol on the source chain; `--to-token` defaults to the destination chain's native gas token (a gas top-up, e.g. `AVAX` → `ETH` when bridging to Base) and can be overridden for a same-asset bridge. Dry-run by default; `--execute` broadcasts on the source chain and prints the source tx hash, source explorer URL, and the LiFi status URL (`--poll` then waits for the cross-chain leg to settle). Safety rails: **self-bridge only** (a `--to-address` that differs from the signer is refused), and the bridge is refused if the quote's implied slippage exceeds `--slippage` (default 1.0%). Because LiFi picks the cheapest route per quote, a cross-asset bridge may land at or above the cap depending on the route — re-quote, or raise `--slippage` if the price impact is acceptable. RPCs are overridable via `BRIDGE_<CHAIN>_RPC`; `LIFI_API_KEY` is sent on quote/status calls if set.
184
+
174
185
  ## Configuration
175
186
 
176
187
  | Env var | Default | Purpose |
@@ -2,7 +2,9 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  primecli/__init__.py
5
+ primecli/_wallets.py
5
6
  primecli/arbprime.py
7
+ primecli/bridge.py
6
8
  primecli/degenprime.py
7
9
  primecli/deltaprime.py
8
10
  primecli/health_monitor.py
@@ -13,6 +15,7 @@ primecli.egg-info/entry_points.txt
13
15
  primecli.egg-info/requires.txt
14
16
  primecli.egg-info/top_level.txt
15
17
  tests/test_aero_rebalance.py
18
+ tests/test_bridge.py
16
19
  tests/test_cross_file_identity.py
17
20
  tests/test_gas_limit.py
18
21
  tests/test_gas_pricing.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
2
  arbprime = primecli.arbprime:main
3
+ bridge = primecli.bridge:main
3
4
  degenprime = primecli.degenprime:main
4
5
  deltaprime = primecli.deltaprime:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "primecli"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche + Arbitrum) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -42,6 +42,7 @@ Documentation = "https://github.com/Mnemosyne-quest/primecli/tree/main/docs"
42
42
  deltaprime = "primecli.deltaprime:main"
43
43
  degenprime = "primecli.degenprime:main"
44
44
  arbprime = "primecli.arbprime:main"
45
+ bridge = "primecli.bridge:main"
45
46
 
46
47
  [tool.setuptools.packages.find]
47
48
  include = ["primecli*"]
@@ -0,0 +1,239 @@
1
+ """Tests for the cross-chain bridge command (primecli.bridge).
2
+
3
+ Pure/offline like the rest of the suite: the one path that would hit the network
4
+ (LiFi quote) is monkeypatched, and key resolution is monkeypatched so no real
5
+ private key is read. Nothing here builds an RPC connection or signs a tx.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from decimal import Decimal
11
+
12
+ import pytest
13
+
14
+ from primecli import bridge
15
+
16
+
17
+ # ── arg parsing ───────────────────────────────────────────────────────────────
18
+
19
+
20
+ def test_parser_basic():
21
+ args = bridge.build_parser().parse_args(
22
+ ["--as", "core1", "--from", "avalanche", "--to", "base",
23
+ "--token", "AVAX", "--amount", "1"]
24
+ )
25
+ assert args.agent == "core1"
26
+ assert args.from_chain == "avalanche"
27
+ assert args.to_chain == "base"
28
+ assert args.token == "AVAX"
29
+ assert args.amount == "1"
30
+ assert args.to_token is None # defaults to dest-native downstream
31
+ assert args.to_address is None
32
+ assert args.slippage == 1.0 # default cap
33
+ assert args.execute is False # dry-run by default
34
+ assert args.poll is False
35
+
36
+
37
+ def test_parser_rejects_unknown_chain():
38
+ with pytest.raises(SystemExit):
39
+ bridge.build_parser().parse_args(
40
+ ["--as", "core1", "--from", "ethereum", "--to", "base",
41
+ "--token", "ETH", "--amount", "1"]
42
+ )
43
+
44
+
45
+ def test_parser_requires_agent():
46
+ with pytest.raises(SystemExit):
47
+ bridge.build_parser().parse_args(
48
+ ["--from", "avalanche", "--to", "base", "--token", "AVAX", "--amount", "1"]
49
+ )
50
+
51
+
52
+ # ── token resolution ──────────────────────────────────────────────────────────
53
+
54
+
55
+ def test_resolve_native_token():
56
+ t = bridge.resolve_token("avalanche", "avax")
57
+ assert t["native"] is True
58
+ assert t["address"] is None
59
+ assert t["lifi"] == bridge.NATIVE_SENTINEL
60
+ assert t["decimals"] == 18
61
+
62
+
63
+ def test_resolve_erc20_token():
64
+ t = bridge.resolve_token("base", "usdc")
65
+ assert t["native"] is False
66
+ assert t["lifi"] == t["address"]
67
+ assert t["decimals"] == 6
68
+
69
+
70
+ def test_resolve_unknown_token_refuses():
71
+ with pytest.raises(SystemExit):
72
+ bridge.resolve_token("base", "DOGE")
73
+
74
+
75
+ # ── hex/int tolerance (LiFi returns mixed types) ──────────────────────────────
76
+
77
+
78
+ @pytest.mark.parametrize("value,expected", [
79
+ (123, 123),
80
+ ("123", 123),
81
+ ("0x7b", 123),
82
+ ("0X7B", 123),
83
+ (None, 0),
84
+ ])
85
+ def test_hexint(value, expected):
86
+ assert bridge._hexint(value) == expected
87
+
88
+
89
+ # ── preview math + slippage gate ──────────────────────────────────────────────
90
+
91
+
92
+ def _quote(to_amount, to_amount_min, tool="across", fee_usd=0.12, gas_usd=0.30):
93
+ """Minimal LiFi-quote shape covering the fields build_preview reads."""
94
+ return {
95
+ "tool": tool,
96
+ "estimate": {
97
+ "toAmount": str(to_amount),
98
+ "toAmountMin": str(to_amount_min),
99
+ "feeCosts": [{"amountUSD": str(fee_usd)}],
100
+ "gasCosts": [{"amountUSD": str(gas_usd)}],
101
+ },
102
+ }
103
+
104
+
105
+ def test_build_preview_fields():
106
+ to_tok = bridge.resolve_token("base", "ETH") # 18 decimals
107
+ q = _quote(to_amount=10 ** 18, to_amount_min=int(0.995 * 10 ** 18))
108
+ p = bridge.build_preview(q, "avalanche", "base",
109
+ bridge.resolve_token("avalanche", "AVAX"), to_tok,
110
+ Decimal("1"), 0.01)
111
+ assert p["from_chain"] == "avalanche"
112
+ assert p["to_chain"] == "base"
113
+ assert p["from_token"] == "AVAX"
114
+ assert p["to_token"] == "ETH"
115
+ assert p["to_amount"] == Decimal(1)
116
+ assert p["tool"] == "across"
117
+ assert p["fee_usd"] == pytest.approx(0.12)
118
+ assert p["gas_usd"] == pytest.approx(0.30)
119
+ # 1.0 -> 0.995 is 0.5% implied slippage
120
+ assert p["implied_slippage"] == pytest.approx(0.005, abs=1e-6)
121
+
122
+
123
+ def test_enforce_slippage_passes_within_cap():
124
+ to_tok = bridge.resolve_token("base", "ETH")
125
+ q = _quote(to_amount=10 ** 18, to_amount_min=int(0.995 * 10 ** 18))
126
+ p = bridge.build_preview(q, "avalanche", "base",
127
+ bridge.resolve_token("avalanche", "AVAX"), to_tok,
128
+ Decimal("1"), 0.01)
129
+ bridge.enforce_slippage(p) # 0.5% < 1% cap → no raise
130
+
131
+
132
+ def test_enforce_slippage_refuses_over_cap():
133
+ to_tok = bridge.resolve_token("base", "ETH")
134
+ q = _quote(to_amount=10 ** 18, to_amount_min=int(0.97 * 10 ** 18)) # 3% slippage
135
+ p = bridge.build_preview(q, "avalanche", "base",
136
+ bridge.resolve_token("avalanche", "AVAX"), to_tok,
137
+ Decimal("1"), 0.01) # 1% cap
138
+ with pytest.raises(SystemExit) as ei:
139
+ bridge.enforce_slippage(p)
140
+ assert "slippage" in str(ei.value).lower()
141
+
142
+
143
+ # ── self-bridge enforcement + full dry-run (mocked quote, no network) ──────────
144
+
145
+ SIGNER = "0x8282fb51649Ce5f474db3e274C47ed04C97b504B"
146
+
147
+
148
+ @pytest.fixture
149
+ def fake_signer(monkeypatch):
150
+ """Make _agent_key return a deterministic key whose address is SIGNER, so no
151
+ real secret is read and Account.from_key yields a known address."""
152
+ # Private key for the SIGNER address above is not known; instead patch
153
+ # Account.from_key to return a stub account with .address == SIGNER.
154
+ class _Acct:
155
+ address = SIGNER
156
+
157
+ def sign_transaction(self, tx): # never called in dry-run
158
+ raise AssertionError("sign_transaction must not run in a dry-run test")
159
+
160
+ monkeypatch.setattr(bridge, "_agent_key", lambda agent: "0x" + "11" * 32)
161
+ monkeypatch.setattr(bridge.Account, "from_key", staticmethod(lambda key: _Acct()))
162
+ return _Acct()
163
+
164
+
165
+ def test_run_refuses_foreign_to_address(fake_signer):
166
+ args = bridge.build_parser().parse_args(
167
+ ["--as", "core1", "--from", "avalanche", "--to", "base",
168
+ "--token", "AVAX", "--amount", "1",
169
+ "--to-address", "0x000000000000000000000000000000000000dEaD"]
170
+ )
171
+ with pytest.raises(SystemExit) as ei:
172
+ bridge.run(args)
173
+ assert "self-bridge" in str(ei.value).lower()
174
+
175
+
176
+ def test_run_allows_own_to_address(fake_signer, monkeypatch):
177
+ # Passing the signer's own address explicitly must be accepted (and reach the quote).
178
+ captured = {}
179
+
180
+ def _fake_quote(from_chain, to_chain, from_tok, to_tok, raw_amount, address, slippage):
181
+ captured["address"] = address
182
+ captured["raw_amount"] = raw_amount
183
+ return _quote(to_amount=10 ** 18, to_amount_min=int(0.997 * 10 ** 18))
184
+
185
+ monkeypatch.setattr(bridge, "lifi_quote", _fake_quote)
186
+ args = bridge.build_parser().parse_args(
187
+ ["--as", "core1", "--from", "avalanche", "--to", "base",
188
+ "--token", "AVAX", "--amount", "1", "--to-address", SIGNER]
189
+ )
190
+ bridge.run(args) # dry-run: no execute, must not raise
191
+ assert captured["address"] == SIGNER
192
+ assert captured["raw_amount"] == 10 ** 18 # 1 AVAX at 18 decimals
193
+
194
+
195
+ def test_run_dryrun_core1_avax_to_base(fake_signer, monkeypatch, capsys):
196
+ """The motivating use case: core1 bridges 1 AVAX (Avalanche) -> ETH (Base),
197
+ dry-run. Mocked quote, asserts the preview is sane and nothing broadcasts."""
198
+ def _fake_quote(from_chain, to_chain, from_tok, to_tok, raw_amount, address, slippage):
199
+ assert from_chain == "avalanche" and to_chain == "base"
200
+ assert from_tok["symbol"] == "AVAX" and to_tok["symbol"] == "ETH"
201
+ assert to_tok["native"] is True # default dest token = Base native gas token
202
+ return _quote(to_amount=int(0.0123 * 10 ** 18),
203
+ to_amount_min=int(0.0122 * 10 ** 18), tool="across")
204
+
205
+ monkeypatch.setattr(bridge, "lifi_quote", _fake_quote)
206
+ args = bridge.build_parser().parse_args(
207
+ ["--as", "core1", "--from", "avalanche", "--to", "base",
208
+ "--token", "AVAX", "--amount", "1"]
209
+ )
210
+ bridge.run(args)
211
+ out = capsys.readouterr().out
212
+ assert "BRIDGE (DRY-RUN)" in out
213
+ assert "AVAX on avalanche" in out
214
+ assert "ETH on base" in out
215
+ assert "Dry-run only" in out
216
+ assert "Broadcast" not in out # nothing signed/sent
217
+
218
+
219
+ def test_run_dryrun_refuses_when_slippage_blown(fake_signer, monkeypatch):
220
+ def _fake_quote(*a, **k):
221
+ return _quote(to_amount=10 ** 18, to_amount_min=int(0.95 * 10 ** 18)) # 5%
222
+
223
+ monkeypatch.setattr(bridge, "lifi_quote", _fake_quote)
224
+ args = bridge.build_parser().parse_args(
225
+ ["--as", "core1", "--from", "avalanche", "--to", "base",
226
+ "--token", "AVAX", "--amount", "1"] # default 1% cap
227
+ )
228
+ with pytest.raises(SystemExit):
229
+ bridge.run(args)
230
+
231
+
232
+ def test_run_refuses_same_chain(fake_signer):
233
+ args = bridge.build_parser().parse_args(
234
+ ["--as", "core1", "--from", "base", "--to", "base",
235
+ "--token", "ETH", "--amount", "1"]
236
+ )
237
+ with pytest.raises(SystemExit) as ei:
238
+ bridge.run(args)
239
+ assert "same-chain" in str(ei.value).lower() or "swap" in str(ei.value).lower()
File without changes
File without changes
File without changes
File without changes