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.
- {primecli-0.9.0 → primecli-0.10.0}/PKG-INFO +14 -3
- {primecli-0.9.0 → primecli-0.10.0}/README.md +13 -2
- primecli-0.10.0/primecli/_wallets.py +46 -0
- primecli-0.10.0/primecli/bridge.py +406 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli/degenprime.py +54 -73
- {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/PKG-INFO +14 -3
- {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/SOURCES.txt +3 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/entry_points.txt +1 -0
- {primecli-0.9.0 → primecli-0.10.0}/pyproject.toml +2 -1
- primecli-0.10.0/tests/test_bridge.py +239 -0
- {primecli-0.9.0 → primecli-0.10.0}/LICENSE +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli/__init__.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli/arbprime.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli/deltaprime.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli/health_monitor.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/setup.cfg +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_aero_rebalance.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_cross_file_identity.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_gas_limit.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_gas_pricing.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_health_meter.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_health_monitor.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_paraswap_validator.py +0 -0
- {primecli-0.9.0 → primecli-0.10.0}/tests/test_redstone_encoding.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/primecli/)
|
|
38
38
|
[](LICENSE)
|
|
39
39
|
|
|
40
|
-
`primecli` installs
|
|
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.
|
|
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
|
[](https://pypi.org/project/primecli/)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
`primecli` installs
|
|
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.
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 ~
|
|
4357
|
-
|
|
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
|
-
|
|
4369
|
-
|
|
4370
|
-
start
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
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.
|
|
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
|
[](https://pypi.org/project/primecli/)
|
|
38
38
|
[](LICENSE)
|
|
39
39
|
|
|
40
|
-
`primecli` installs
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "primecli"
|
|
7
|
-
version = "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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|