primecli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- primecli/degenprime.py +1875 -0
- primecli/deltaprime.py +3974 -0
- primecli-0.1.0.dist-info/METADATA +226 -0
- primecli-0.1.0.dist-info/RECORD +9 -0
- primecli-0.1.0.dist-info/WHEEL +5 -0
- primecli-0.1.0.dist-info/entry_points.txt +3 -0
- primecli-0.1.0.dist-info/licenses/LICENSE +21 -0
- primecli-0.1.0.dist-info/top_level.txt +1 -0
primecli/degenprime.py
ADDED
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DegenPrime Protocol interaction module (Base, chain 8453).
|
|
3
|
+
|
|
4
|
+
Sister protocol to DeltaPrime on Avalanche, by the same team (DeltaPrimeLabs),
|
|
5
|
+
sharing the EIP-2535 Diamond + per-user Smart Loan architecture. Lending pools take
|
|
6
|
+
direct EOA deposits/withdrawals. Borrowing and leverage go through a Degen Account:
|
|
7
|
+
a per-user SmartLoan (diamond proxy) created via the SmartLoansFactory. The EOA owns
|
|
8
|
+
it; borrow/repay/fund run on the Degen Account, which itself talks to the pools.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
degenprime pool-info [usdc|weth|cbbtc|aero|brett|kaito|cbdoge|cbxrp|all]
|
|
12
|
+
degenprime my-positions
|
|
13
|
+
degenprime deposit --pool usdc --amount 100 [--execute]
|
|
14
|
+
degenprime withdraw --pool usdc --amount 100 [--execute]
|
|
15
|
+
degenprime create-account [--execute]
|
|
16
|
+
degenprime create-account --fund-pool usdc --fund-amount 100 [--execute]
|
|
17
|
+
degenprime summary
|
|
18
|
+
degenprime fund --pool usdc --amount 100 [--execute]
|
|
19
|
+
degenprime borrow --pool usdc --amount 100 [--execute]
|
|
20
|
+
degenprime repay --pool usdc --amount 100 [--execute]
|
|
21
|
+
degenprime swap --from USDC --to ETH --amount 10 [--slippage 0.5] [--execute]
|
|
22
|
+
degenprime swap-debt --from ETH --to USDC --amount 100 [--slippage 0.5] [--execute]
|
|
23
|
+
degenprime withdraw-collateral --pool usdc --amount 100 [--execute]
|
|
24
|
+
degenprime withdrawal-intents
|
|
25
|
+
degenprime execute-withdrawal --pool usdc [--index N] [--execute]
|
|
26
|
+
degenprime cancel-withdrawal --pool usdc --index N [--execute]
|
|
27
|
+
degenprime aerodrome-positions
|
|
28
|
+
|
|
29
|
+
Configuration (env vars):
|
|
30
|
+
DEGENPRIME_PRIVATE_KEY Raw 0x... private key for the signer. Falls back to
|
|
31
|
+
DELTAPRIME_PRIVATE_KEY if unset (same EVM key works on
|
|
32
|
+
both chains).
|
|
33
|
+
DEGENPRIME_KEY_FILE Path to a file containing the 0x key (alternative to
|
|
34
|
+
the env var). Falls back to DELTAPRIME_KEY_FILE.
|
|
35
|
+
DEGENPRIME_RPC Base RPC URL (defaults to base.publicnode.com).
|
|
36
|
+
--key <0xhex> One-off CLI override (takes precedence over env vars).
|
|
37
|
+
|
|
38
|
+
summary reports live solvency (health ratio, total value, debt, solvent flag) from the
|
|
39
|
+
Diamond's SolvencyFacet, read via eth_call with a RedStone price payload appended (falls
|
|
40
|
+
back to balances-only if the gateway is unreachable).
|
|
41
|
+
|
|
42
|
+
Collateral withdrawal is universally time-locked on DegenPrime (NOT just risky assets):
|
|
43
|
+
withdraw-collateral registers a WithdrawalIntent (createWithdrawalIntent, no RedStone).
|
|
44
|
+
The intent becomes executable ~24h later for a 48h window (24h-72h total);
|
|
45
|
+
execute-withdrawal then pulls it to the wallet (executeWithdrawalIntent, RedStone-gated).
|
|
46
|
+
withdrawal-intents lists pending intents + per-asset available balance (oracle-free reads).
|
|
47
|
+
cancel-withdrawal cancels a pending intent before maturity.
|
|
48
|
+
|
|
49
|
+
Leverage flow: create-account -> fund (collateral) -> borrow -> repay -> withdraw.
|
|
50
|
+
fund moves collateral from the wallet into the Degen Account; borrow needs a funded
|
|
51
|
+
account. ERC20 assets approve the account then call fund(); native ETH (weth pool)
|
|
52
|
+
uses the payable depositNativeToken(). create-account --fund-* does both in one tx
|
|
53
|
+
via createAndFundLoan() (ERC20 only).
|
|
54
|
+
|
|
55
|
+
swap trades one in-account asset for another on the Degen Account via ParaSwap v6:
|
|
56
|
+
the ParaSwap API on Base (network=8453) builds the swap calldata (/prices price route
|
|
57
|
+
-> /transactions tx data). The facet takes paraSwapV6(bytes4 selector, bytes data) -
|
|
58
|
+
we split the API calldata into its 4-byte selector + remaining bytes and pass them
|
|
59
|
+
through. Only the two router methods the facet decodes are accepted: swapExactAmountIn
|
|
60
|
+
(0xe3ead59e) and swapExactAmountInOnUniswapV3 (0x876a02f6). The facet enforces a hard
|
|
61
|
+
5% slippage cap (RedStone-priced) regardless of --slippage. Carries remainsSolvent, so
|
|
62
|
+
--execute appends a RedStone signed-price payload to the calldata. Asset names are the
|
|
63
|
+
bytes32 symbols, not the wrapped-token names (e.g. ETH not WETH).
|
|
64
|
+
|
|
65
|
+
swap-debt refinances debt from one asset into another in a single tx via
|
|
66
|
+
swapDebtParaSwap(_fromAsset, _toAsset, _repayAmount, _borrowAmount, selector, data):
|
|
67
|
+
it borrows --amount of the NEW debt asset (--to), ParaSwaps it into the OLD debt
|
|
68
|
+
asset (--from), and repays the old debt. --from is the existing debt being
|
|
69
|
+
refinanced; --to is the new debt taken on. RedStone-gated on execute.
|
|
70
|
+
|
|
71
|
+
aerodrome-positions is read-only: lists the Aerodrome NFT tokenIds the Degen Account
|
|
72
|
+
owns/has staked via the diamond's getOwnedStakedAerodromeTokenIds view. Write paths
|
|
73
|
+
(add/remove/stake liquidity) are deferred to v2 - signatures vary by Aerodrome version
|
|
74
|
+
and need on-chain probing per market.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
import json, os, sys, time, base64
|
|
78
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
import requests
|
|
81
|
+
from eth_account import Account
|
|
82
|
+
from eth_keys import keys as eth_keys
|
|
83
|
+
from eth_abi import decode as abi_decode
|
|
84
|
+
from web3 import Web3
|
|
85
|
+
|
|
86
|
+
# Default Base RPC. mainnet.base.org rate-limits hard (429 within a few calls); the
|
|
87
|
+
# publicnode endpoint is fronted by a load balancer with much higher anonymous limits
|
|
88
|
+
# and has been the most reliable free option for this tool's traffic pattern (lots of
|
|
89
|
+
# small reads in quick succession for `pool-info all` / `my-positions` / `summary`).
|
|
90
|
+
# Override with the DEGENPRIME_RPC env var for paid Alchemy/QuickNode/Infura endpoints.
|
|
91
|
+
BASE_RPC = os.environ.get("DEGENPRIME_RPC", "https://base.publicnode.com")
|
|
92
|
+
EXPLORER = "https://basescan.org"
|
|
93
|
+
CHAIN_ID = 8453
|
|
94
|
+
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
95
|
+
|
|
96
|
+
# ── Signing key resolution ──────────────────────────────────────────────────
|
|
97
|
+
# The Degen Account is derived on-chain from the wallet owner (getLoansForOwner),
|
|
98
|
+
# so each user automatically operates on their own Degen Account — no per-user
|
|
99
|
+
# addresses are hardcoded.
|
|
100
|
+
#
|
|
101
|
+
# Key resolution order (first hit wins; see resolve_private_key):
|
|
102
|
+
# 1. --key <0xhex> CLI flag -> raw 0x... key (one-off escape hatch)
|
|
103
|
+
# 2. DEGENPRIME_PRIVATE_KEY env var -> raw 0x... key (primary path)
|
|
104
|
+
# 3. DEGENPRIME_KEY_FILE env var -> path to a file containing the 0x key
|
|
105
|
+
# 4. DELTAPRIME_PRIVATE_KEY / DELTAPRIME_KEY_FILE — fallback, since the same
|
|
106
|
+
# EVM key works on both chains.
|
|
107
|
+
#
|
|
108
|
+
# The CLI key (#1) is set by main() before the command runs; the env vars are
|
|
109
|
+
# read lazily so read-only commands that don't sign never need a key at all.
|
|
110
|
+
_CLI_KEY = None # set by the --key CLI flag in main()
|
|
111
|
+
|
|
112
|
+
# Core protocol addresses (verified on Base 2026-05-29).
|
|
113
|
+
FACTORY_PROXY = "0x5A6a0e2702cF4603a098C3Df01f3F0DF56115456" # SmartLoansFactory TUP
|
|
114
|
+
# Diamond beacon. Every Degen Account is a per-user proxy that delegates here, so the
|
|
115
|
+
# facet ABIs (borrow/repay/fund + view fns) are reachable at any deployed account.
|
|
116
|
+
SMART_LOAN_DIAMOND = "0x85c2BAA28C1d7A07bFC5C5c9903FFf4c39ae5151"
|
|
117
|
+
# On-chain registry of active pools + collateral assets. getPoolAddress(bytes32 asset)
|
|
118
|
+
# and tokenAddressToSymbol(address) are the source of truth for the 8 pools + 32
|
|
119
|
+
# supported collateral tokens.
|
|
120
|
+
TOKEN_MANAGER = "0x97e74e0A3D2713D87E3fBf6d18F869042F0d0116"
|
|
121
|
+
# Base native wrapped (used by the weth pool's native ETH path).
|
|
122
|
+
WETH = "0x4200000000000000000000000000000000000006"
|
|
123
|
+
|
|
124
|
+
# ParaSwap v6 / Velora aggregator on Base. The Degen Account's ParaSwapFacet.paraSwapV6
|
|
125
|
+
# and SwapDebtFacet.swapDebtParaSwap call this Augustus router with API-built calldata.
|
|
126
|
+
# The router address is shared across chains (v6 unified). The facet only decodes two
|
|
127
|
+
# router methods, so the API route must resolve to one of these selectors:
|
|
128
|
+
# swapExactAmountIn 0xe3ead59e (generic executor route)
|
|
129
|
+
# swapExactAmountInOnUniV3 0x876a02f6 (Uniswap-V3 direct route)
|
|
130
|
+
PARASWAP_API = "https://apiv5.paraswap.io"
|
|
131
|
+
PARASWAP_AUGUSTUS = "0x6A000F20005980200259B80c5102003040001068"
|
|
132
|
+
PARASWAP_SUPPORTED_SELECTORS = {"0xe3ead59e", "0x876a02f6"}
|
|
133
|
+
# Executors the facet whitelists. Lowercased. Starting set mirrors DeltaPrime's - the
|
|
134
|
+
# v6 router is shared, so the same executors are plausible candidates. Real txs will
|
|
135
|
+
# reveal missing ones with InvalidExecutor reverts; add as they show up.
|
|
136
|
+
PARASWAP_EXECUTORS = {
|
|
137
|
+
"0xdef171fe48cf0115b1d80b88dc8eab59176fee57",
|
|
138
|
+
"0x6a000f20005980200259b80c5102003040001068",
|
|
139
|
+
"0x000010036c0190e009a000d0fc3541100a07380a",
|
|
140
|
+
"0x00c600b30fb0400701010f4b080409018b9006e0",
|
|
141
|
+
"0xa0f408a000017007015e0f00320e470d00090a5b",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# RedStone on-demand oracle config for DegenPrime on Base. Verified identical to
|
|
145
|
+
# DeltaPrime - same data service, same 5 authorised signers, same 3-of-5 threshold,
|
|
146
|
+
# same marker bytes, same gateways. The Degen Account's solvency math reads signed
|
|
147
|
+
# prices appended to the tx calldata, same wrapping as on Avalanche.
|
|
148
|
+
REDSTONE_DATA_SERVICE = "redstone-primary-prod"
|
|
149
|
+
REDSTONE_SIGNERS_THRESHOLD = 3
|
|
150
|
+
REDSTONE_MARKER = bytes.fromhex("000002ed57011e0000")
|
|
151
|
+
REDSTONE_GATEWAYS = [
|
|
152
|
+
"https://oracle-gateway-1.a.redstone.finance",
|
|
153
|
+
"https://oracle-gateway-2.a.redstone.finance",
|
|
154
|
+
]
|
|
155
|
+
REDSTONE_VALUE_DECIMALS = 8
|
|
156
|
+
# Stored lower-case because _redstone_package_signer returns checksummed addresses and
|
|
157
|
+
# the filter compares signer.lower() in this set.
|
|
158
|
+
REDSTONE_AUTHORISED_SIGNERS = {
|
|
159
|
+
"0x8bb8f32df04c8b654987daaed53d6b6091e3b774",
|
|
160
|
+
"0xdeb22f54738d54976c4c0fe5ce6d408e40d88499",
|
|
161
|
+
"0x51ce04be4b3e32572c4ec9135221d0691ba7d202",
|
|
162
|
+
"0xdd682daec5a90dd295d14da4b0bec9281017b5be",
|
|
163
|
+
"0x9c5ae89c4af6aa32ce58588dbaf90d18a855b6de",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Active lending pools on Base, verified live (totalSupply > 0, all wired in
|
|
167
|
+
# TokenManager 2026-05-29). The proxy is the user-facing Pool TUP; token is the
|
|
168
|
+
# underlying ERC20; symbol is the on-chain bytes32 the Diamond uses; native flags
|
|
169
|
+
# the pool whose underlying is wrapped ETH (uses depositNativeToken() for the
|
|
170
|
+
# fund() path on the Degen Account side).
|
|
171
|
+
POOLS = {
|
|
172
|
+
"usdc": {"proxy": "0x2Fc7641F6A569d0e678C473B95C2Fc56A88aDF75", "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "symbol": "USDC", "decimals": 6, "native": False},
|
|
173
|
+
"weth": {"proxy": "0x81b0b59C7967479EC5Ce55cF6588bf314C3E4852", "token": "0x4200000000000000000000000000000000000006", "symbol": "ETH", "decimals": 18, "native": True},
|
|
174
|
+
"cbbtc": {"proxy": "0xCA8C954073054551B99EDee4e1F20c3d08778329", "token": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", "symbol": "cbBTC", "decimals": 8, "native": False},
|
|
175
|
+
"aero": {"proxy": "0x4524D39Ca5b32527E7AF6c288Ad3E2871B9f343B", "token": "0x940181a94A35A4569E4529A3CDfB74e38FD98631", "symbol": "AERO", "decimals": 18, "native": False},
|
|
176
|
+
"brett": {"proxy": "0x6c307F792FfDA3f63D467416C9AEdfeE2DD27ECF", "token": "0x532f27101965dd16442E59d40670FaF5eBB142E4", "symbol": "BRETT", "decimals": 18, "native": False},
|
|
177
|
+
"kaito": {"proxy": "0x293E41F1405Dde427B41c0074dee0aC55D064825", "token": "0x98d0baa52b2D063E780DE12F615f963Fe8537553", "symbol": "KAITO", "decimals": 18, "native": False},
|
|
178
|
+
"cbdoge": {"proxy": "0xAf61B10BDB78e31fdbC5Da4e57d60e32aFe468B9", "token": "0xcbD06E5A2B0C65597161de254AA074E489dEb510", "symbol": "cbDOGE", "decimals": 8, "native": False},
|
|
179
|
+
"cbxrp": {"proxy": "0x056076e717332403Bc23B2D4F6D87683ceF582B9", "token": "0xcb585250f852C6c6bf90434AB21A00f02833a4af", "symbol": "cbXRP", "decimals": 6, "native": False},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# RedStone primary-prod feed availability on Base. The SolvencyFacet sources missing
|
|
183
|
+
# symbols from BaseOracle TWAP internally, so we filter the payload to only the symbols
|
|
184
|
+
# the gateway actually has feeds for. Probed against the gateway 2026-05-29.
|
|
185
|
+
REDSTONE_AVAILABLE_FEEDS = {
|
|
186
|
+
"USDC", "ETH", "cbBTC", "AERO", "BRETT", "KAITO", "DEGEN", "MOG",
|
|
187
|
+
"weETH", "EUROC", "USDT", "LBTC", "ezETH",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_abi_cache = {}
|
|
191
|
+
_impl_cache = {}
|
|
192
|
+
# Cache for TokenManager symbol/decimals lookups (the in-account view shows non-pool
|
|
193
|
+
# collateral too; symbol+decimals reads are pure but cheap to memoise).
|
|
194
|
+
_asset_meta_cache = {}
|
|
195
|
+
|
|
196
|
+
def get_w3():
|
|
197
|
+
"""Base RPC client. Base has no POA middleware - it's a standard EVM chain;
|
|
198
|
+
middleware injection is not needed (and would error on the Base block headers)."""
|
|
199
|
+
return Web3(Web3.HTTPProvider(BASE_RPC))
|
|
200
|
+
|
|
201
|
+
def _tx_gas_price(w3) -> int:
|
|
202
|
+
"""Gas price for broadcasts: 2x the current network price with a 1 gwei floor.
|
|
203
|
+
Base's base fee is ~0.001 gwei, so an unfloored tx can strand if the base fee
|
|
204
|
+
ticks up after submission. The bump guarantees timely inclusion and gives
|
|
205
|
+
headroom to REPLACE a stranded same-nonce tx. Cost is negligible on Base."""
|
|
206
|
+
return max(int(w3.eth.gas_price * 2), 10**9)
|
|
207
|
+
|
|
208
|
+
def resolve_private_key():
|
|
209
|
+
"""Resolve the signing key per the documented precedence:
|
|
210
|
+
1. --key <0xhex> CLI flag
|
|
211
|
+
2. DEGENPRIME_PRIVATE_KEY env var
|
|
212
|
+
3. DEGENPRIME_KEY_FILE env var (path to a file containing the 0x key)
|
|
213
|
+
4. DELTAPRIME_PRIVATE_KEY / DELTAPRIME_KEY_FILE (same key, both chains)
|
|
214
|
+
Raises with a clear message if none of the four are set."""
|
|
215
|
+
if _CLI_KEY:
|
|
216
|
+
return _CLI_KEY.strip()
|
|
217
|
+
for env_var in ("DEGENPRIME_PRIVATE_KEY", "DELTAPRIME_PRIVATE_KEY"):
|
|
218
|
+
raw = os.environ.get(env_var)
|
|
219
|
+
if raw:
|
|
220
|
+
return raw.strip()
|
|
221
|
+
for path_var in ("DEGENPRIME_KEY_FILE", "DELTAPRIME_KEY_FILE"):
|
|
222
|
+
key_file = os.environ.get(path_var)
|
|
223
|
+
if key_file:
|
|
224
|
+
try:
|
|
225
|
+
return Path(key_file).read_text().strip()
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
raise RuntimeError(f"{path_var} points at {key_file} but the file does not exist.")
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
"No signing key found. Set DEGENPRIME_PRIVATE_KEY (raw 0x... key) or "
|
|
230
|
+
"DEGENPRIME_KEY_FILE (path to a file with the key), or pass --key <0xhex>. "
|
|
231
|
+
"DELTAPRIME_PRIVATE_KEY / DELTAPRIME_KEY_FILE also work (same key, both chains)."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def get_account() -> Account:
|
|
235
|
+
return Account.from_key(resolve_private_key())
|
|
236
|
+
|
|
237
|
+
# Basescan's v1 API is deprecated (returns "switch to Etherscan API V2" since 2026),
|
|
238
|
+
# and the v2 multichain endpoint (api.etherscan.io/v2/api?chainid=8453) requires an API
|
|
239
|
+
# key with no anonymous reads. Rather than depend on an API key for what is a tiny,
|
|
240
|
+
# stable ABI surface, we hand-curate the Pool + Factory ABIs below and resolve proxy
|
|
241
|
+
# implementations directly via the EIP-1967 storage slot.
|
|
242
|
+
EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
|
|
243
|
+
|
|
244
|
+
# Pool ABI - minimum surface for lending pool ops. DegenPrime pools share the
|
|
245
|
+
# DeltaPrime Pool implementation: deposit/withdraw/instantWithdraw, rate views,
|
|
246
|
+
# borrow accounting, and ERC20 receipt-token reads. Function names are verified
|
|
247
|
+
# against Harvest's DegenPrime strategies (which call these directly) and the
|
|
248
|
+
# DeltaPrimeLabs/deltaprime-contracts Pool.sol source.
|
|
249
|
+
POOL_ABI = json.loads(
|
|
250
|
+
'['
|
|
251
|
+
'{"inputs":[{"name":"_amount","type":"uint256"}],"name":"deposit","outputs":[],"stateMutability":"nonpayable","type":"function"},'
|
|
252
|
+
'{"inputs":[],"name":"depositNativeToken","outputs":[],"stateMutability":"payable","type":"function"},'
|
|
253
|
+
'{"inputs":[{"name":"_amount","type":"uint256"}],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},'
|
|
254
|
+
'{"inputs":[{"name":"_amount","type":"uint256"}],"name":"withdrawNativeToken","outputs":[],"stateMutability":"nonpayable","type":"function"},'
|
|
255
|
+
'{"inputs":[{"name":"_amount","type":"uint256"}],"name":"instantWithdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},'
|
|
256
|
+
'{"inputs":[],"name":"totalSupply","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
257
|
+
'{"inputs":[],"name":"totalBorrowed","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
258
|
+
'{"inputs":[],"name":"getDepositRate","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
259
|
+
'{"inputs":[],"name":"getBorrowingRate","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
260
|
+
'{"inputs":[],"name":"tokenAddress","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"},'
|
|
261
|
+
'{"inputs":[{"name":"_user","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
262
|
+
'{"inputs":[{"name":"_user","type":"address"}],"name":"getBorrowed","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}'
|
|
263
|
+
']'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# SmartLoansFactory ABI - minimum surface. Same shape as DeltaPrime's SmartLoansFactory.
|
|
267
|
+
FACTORY_ABI = json.loads(
|
|
268
|
+
'['
|
|
269
|
+
'{"inputs":[],"name":"createLoan","outputs":[{"type":"address"}],"stateMutability":"nonpayable","type":"function"},'
|
|
270
|
+
'{"inputs":[{"name":"_fundedAsset","type":"bytes32"},{"name":"_amount","type":"uint256"}],"name":"createAndFundLoan","outputs":[{"type":"address"}],"stateMutability":"nonpayable","type":"function"},'
|
|
271
|
+
'{"inputs":[{"name":"_user","type":"address"}],"name":"getLoansForOwner","outputs":[{"type":"address[]"}],"stateMutability":"view","type":"function"},'
|
|
272
|
+
'{"inputs":[{"name":"_loan","type":"address"}],"name":"getOwnerOfLoan","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"},'
|
|
273
|
+
'{"inputs":[],"name":"getLoansLength","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
274
|
+
'{"inputs":[{"name":"_from","type":"uint256"},{"name":"_count","type":"uint256"}],"name":"getLoans","outputs":[{"type":"address[]"}],"stateMutability":"view","type":"function"}'
|
|
275
|
+
']'
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def get_pool_impl(pool_proxy: str) -> str:
|
|
279
|
+
"""Resolve a TUP's implementation via the EIP-1967 storage slot. Caches per-proxy."""
|
|
280
|
+
p = pool_proxy.lower()
|
|
281
|
+
if p not in _impl_cache:
|
|
282
|
+
w3 = get_w3()
|
|
283
|
+
raw = w3.eth.get_storage_at(Web3.to_checksum_address(pool_proxy), int(EIP1967_IMPL_SLOT, 16))
|
|
284
|
+
impl = "0x" + raw.hex()[-40:]
|
|
285
|
+
_impl_cache[p] = impl if int(impl, 16) != 0 else pool_proxy
|
|
286
|
+
return _impl_cache[p]
|
|
287
|
+
|
|
288
|
+
def get_pool_contract(pool_name: str):
|
|
289
|
+
cfg = POOLS[pool_name]
|
|
290
|
+
proxy = Web3.to_checksum_address(cfg["proxy"])
|
|
291
|
+
w3 = get_w3()
|
|
292
|
+
return w3.eth.contract(address=proxy, abi=POOL_ABI), cfg, w3
|
|
293
|
+
|
|
294
|
+
# Minimal Degen Account ABI: only the facet functions this tool calls. The diamond
|
|
295
|
+
# beacon's own ABI is beacon-management only, so the borrow/repay/fund and view
|
|
296
|
+
# selectors live in facets - we hand-pick the verified signatures here rather than
|
|
297
|
+
# enumerate facet contracts at runtime. Selectors probed against a live Degen Account
|
|
298
|
+
# 2026-05-29 (every entry below returned EXISTS_REVERT/REVERT, never MISSING).
|
|
299
|
+
PRIME_ACCOUNT_ABI = [
|
|
300
|
+
# Core: AssetsOperations + SmartLoanWrappedNativeToken facets
|
|
301
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
|
|
302
|
+
"name": "borrow", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
303
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
|
|
304
|
+
"name": "repay", "outputs": [], "stateMutability": "payable", "type": "function"},
|
|
305
|
+
{"inputs": [{"name": "_fundedAsset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
|
|
306
|
+
"name": "fund", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
307
|
+
{"inputs": [], "name": "depositNativeToken", "outputs": [],
|
|
308
|
+
"stateMutability": "payable", "type": "function"},
|
|
309
|
+
# SmartLoanView facet
|
|
310
|
+
{"inputs": [], "name": "getAllOwnedAssets", "outputs": [{"type": "bytes32[]"}],
|
|
311
|
+
"stateMutability": "view", "type": "function"},
|
|
312
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getBalance",
|
|
313
|
+
"outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
|
|
314
|
+
{"inputs": [], "name": "getDebts",
|
|
315
|
+
"outputs": [{"components": [{"name": "name", "type": "bytes32"}, {"name": "debt", "type": "uint256"}], "type": "tuple[]"}],
|
|
316
|
+
"stateMutability": "view", "type": "function"},
|
|
317
|
+
{"inputs": [], "name": "getHealthMeter", "outputs": [{"type": "uint256"}],
|
|
318
|
+
"stateMutability": "view", "type": "function"},
|
|
319
|
+
{"inputs": [], "name": "owner", "outputs": [{"type": "address"}],
|
|
320
|
+
"stateMutability": "view", "type": "function"},
|
|
321
|
+
# ParaSwapFacet + SwapDebtFacet - both RedStone-gated (remainsSolvent). selector+data
|
|
322
|
+
# are the ParaSwap Augustus calldata, split into its 4-byte method selector and the
|
|
323
|
+
# remaining ABI-encoded args.
|
|
324
|
+
{"inputs": [{"name": "selector", "type": "bytes4"}, {"name": "data", "type": "bytes"}],
|
|
325
|
+
"name": "paraSwapV6", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
326
|
+
{"inputs": [{"name": "_fromAsset", "type": "bytes32"}, {"name": "_toAsset", "type": "bytes32"},
|
|
327
|
+
{"name": "_repayAmount", "type": "uint256"}, {"name": "_borrowAmount", "type": "uint256"},
|
|
328
|
+
{"name": "selector", "type": "bytes4"}, {"name": "data", "type": "bytes"}],
|
|
329
|
+
"name": "swapDebtParaSwap", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
330
|
+
# SolvencyFacet views - RedStone-gated. getTotalValue/getDebt are 1e18-scaled USD;
|
|
331
|
+
# getHealthRatio is 1e18-scaled (1e18 == liquidation line, so the human ratio is the
|
|
332
|
+
# raw value / 1e18). All revert with 0xe7764c9e on a bare eth_call - a signed
|
|
333
|
+
# RedStone price payload must be appended to the calldata.
|
|
334
|
+
{"inputs": [], "name": "getHealthRatio", "outputs": [{"type": "uint256"}],
|
|
335
|
+
"stateMutability": "view", "type": "function"},
|
|
336
|
+
{"inputs": [], "name": "getTotalValue", "outputs": [{"type": "uint256"}],
|
|
337
|
+
"stateMutability": "view", "type": "function"},
|
|
338
|
+
{"inputs": [], "name": "getDebt", "outputs": [{"type": "uint256"}],
|
|
339
|
+
"stateMutability": "view", "type": "function"},
|
|
340
|
+
{"inputs": [], "name": "isSolvent", "outputs": [{"type": "bool"}],
|
|
341
|
+
"stateMutability": "view", "type": "function"},
|
|
342
|
+
# getPrices: 1e8-scaled USD prices for the given symbols. RedStone-gated, so a
|
|
343
|
+
# payload is appended for the read. swap-debt uses it to value-match the borrow vs
|
|
344
|
+
# repay leg against the facet's own 5% cap (the facet calls the same view internally).
|
|
345
|
+
{"inputs": [{"name": "symbols", "type": "bytes32[]"}], "name": "getPrices",
|
|
346
|
+
"outputs": [{"type": "uint256[]"}], "stateMutability": "view", "type": "function"},
|
|
347
|
+
# WithdrawalIntentFacet - universal time-locked collateral withdrawal on DegenPrime.
|
|
348
|
+
# createWithdrawalIntent / cancelWithdrawalIntent are oracle-free; executeWithdrawalIntent
|
|
349
|
+
# is RedStone-gated. IntentInfo's isActionable/isExpired flags make the 24h-72h window
|
|
350
|
+
# readable on-chain.
|
|
351
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
|
|
352
|
+
"name": "createWithdrawalIntent", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
353
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "intentIndices", "type": "uint256[]"}],
|
|
354
|
+
"name": "executeWithdrawalIntent", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
355
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "intentIndex", "type": "uint256"}],
|
|
356
|
+
"name": "cancelWithdrawalIntent", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
|
|
357
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getUserIntents",
|
|
358
|
+
"outputs": [{"components": [{"name": "amount", "type": "uint256"},
|
|
359
|
+
{"name": "actionableAt", "type": "uint256"},
|
|
360
|
+
{"name": "expiresAt", "type": "uint256"},
|
|
361
|
+
{"name": "isPending", "type": "bool"},
|
|
362
|
+
{"name": "isActionable", "type": "bool"},
|
|
363
|
+
{"name": "isExpired", "type": "bool"}], "type": "tuple[]"}],
|
|
364
|
+
"stateMutability": "view", "type": "function"},
|
|
365
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getAvailableBalance",
|
|
366
|
+
"outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
|
|
367
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getTotalIntentAmount",
|
|
368
|
+
"outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
|
|
369
|
+
# Aerodrome read-only - the diamond exposes a list view of owned staked Aerodrome
|
|
370
|
+
# tokenIds. Write paths (add/remove/stake liquidity) deferred to v2; the exact
|
|
371
|
+
# composition view signature varies and needs runtime probing.
|
|
372
|
+
{"inputs": [], "name": "getOwnedStakedAerodromeTokenIds",
|
|
373
|
+
"outputs": [{"type": "uint256[]"}], "stateMutability": "view", "type": "function"},
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# TokenManager ABI - minimal subset for symbol/decimals lookups + supported tokens
|
|
377
|
+
# enumeration. tokenAddressToSymbol returns the bytes32 the diamond uses for the
|
|
378
|
+
# in-account view; getSupportedTokensAddresses lists all 32 collateral assets.
|
|
379
|
+
TOKEN_MANAGER_ABI = [
|
|
380
|
+
{"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getPoolAddress",
|
|
381
|
+
"outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
|
|
382
|
+
{"inputs": [{"name": "_address", "type": "address"}], "name": "tokenAddressToSymbol",
|
|
383
|
+
"outputs": [{"type": "bytes32"}], "stateMutability": "view", "type": "function"},
|
|
384
|
+
{"inputs": [{"name": "_symbol", "type": "bytes32"}], "name": "getAssetAddress",
|
|
385
|
+
"outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
|
|
386
|
+
{"inputs": [], "name": "getSupportedTokensAddresses",
|
|
387
|
+
"outputs": [{"type": "address[]"}], "stateMutability": "view", "type": "function"},
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
# Minimal ERC20 ABI - balanceOf + approve + decimals. Used for wallet token reads,
|
|
391
|
+
# pool deposits, and TokenManager-discovered non-pool collateral metadata.
|
|
392
|
+
ERC20_ABI = json.loads(
|
|
393
|
+
'[{"constant":true,"inputs":[{"name":"a","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
|
|
394
|
+
'{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"type":"function"},'
|
|
395
|
+
'{"constant":true,"inputs":[],"name":"decimals","outputs":[{"type":"uint8"}],"stateMutability":"view","type":"function"}]'
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def get_factory_contract(w3):
|
|
399
|
+
"""SmartLoansFactory - hand-curated ABI (same shape as DeltaPrime's factory)."""
|
|
400
|
+
return w3.eth.contract(address=Web3.to_checksum_address(FACTORY_PROXY), abi=FACTORY_ABI)
|
|
401
|
+
|
|
402
|
+
def get_token_manager(w3):
|
|
403
|
+
return w3.eth.contract(address=Web3.to_checksum_address(TOKEN_MANAGER), abi=TOKEN_MANAGER_ABI)
|
|
404
|
+
|
|
405
|
+
def get_prime_account(w3, owner: str) -> str:
|
|
406
|
+
"""Owner -> Degen Account address. Returns None if none exists.
|
|
407
|
+
|
|
408
|
+
DegenPrime's factory exposes `getLoansForOwner(address) returns address[]` (plural,
|
|
409
|
+
array - different from DeltaPrime's singular `getLoanForOwner` that returns one
|
|
410
|
+
address). Empty array means the owner hasn't minted a Degen Account; we collapse
|
|
411
|
+
that to None. In practice the factory only mints ONE loan per owner (createLoan
|
|
412
|
+
checks ownersToLoans first), so we return the first element when present."""
|
|
413
|
+
loans = get_factory_contract(w3).functions.getLoansForOwner(Web3.to_checksum_address(owner)).call()
|
|
414
|
+
return loans[0] if loans else None
|
|
415
|
+
|
|
416
|
+
def asset_b32(symbol: str) -> bytes:
|
|
417
|
+
return symbol.encode().ljust(32, b"\x00")
|
|
418
|
+
|
|
419
|
+
def pool_to_asset_symbol(pool_name: str) -> str:
|
|
420
|
+
"""Pool key -> on-chain bytes32 asset symbol (the contracts use 'ETH', not 'WETH')."""
|
|
421
|
+
return POOLS[pool_name]["symbol"]
|
|
422
|
+
|
|
423
|
+
def token_price(symbol: str) -> float:
|
|
424
|
+
"""Spot price from KuCoin, best-effort. Returns 0.0 on any failure; callers treat 0
|
|
425
|
+
as 'no price available' and skip the USD display."""
|
|
426
|
+
try:
|
|
427
|
+
r = requests.get(f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={symbol}-USDT", timeout=3)
|
|
428
|
+
if r.status_code == 200 and r.json().get("code") == "200000":
|
|
429
|
+
return float(r.json()["data"]["price"])
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
return 0.0
|
|
433
|
+
|
|
434
|
+
def _asset_meta(w3, symbol: str):
|
|
435
|
+
"""Resolve a bytes32 symbol to (token_address, decimals) via the TokenManager.
|
|
436
|
+
Used for in-account assets that aren't lending pool symbols (memecoin collateral
|
|
437
|
+
like AIXBT, TOSHI, etc.). Cached - reads are pure but the TokenManager call is one
|
|
438
|
+
eth_call + an ERC20.decimals() per asset."""
|
|
439
|
+
if symbol in _asset_meta_cache:
|
|
440
|
+
return _asset_meta_cache[symbol]
|
|
441
|
+
# Pool symbols hit the static map - no on-chain read needed.
|
|
442
|
+
for cfg in POOLS.values():
|
|
443
|
+
if cfg["symbol"] == symbol:
|
|
444
|
+
_asset_meta_cache[symbol] = (cfg["token"], cfg["decimals"])
|
|
445
|
+
return _asset_meta_cache[symbol]
|
|
446
|
+
try:
|
|
447
|
+
tm = get_token_manager(w3)
|
|
448
|
+
addr = tm.functions.getAssetAddress(asset_b32(symbol)).call()
|
|
449
|
+
if int(addr, 16) == 0:
|
|
450
|
+
_asset_meta_cache[symbol] = (None, 18)
|
|
451
|
+
return _asset_meta_cache[symbol]
|
|
452
|
+
tok = w3.eth.contract(address=Web3.to_checksum_address(addr), abi=ERC20_ABI)
|
|
453
|
+
dec = tok.functions.decimals().call()
|
|
454
|
+
_asset_meta_cache[symbol] = (addr, dec)
|
|
455
|
+
return _asset_meta_cache[symbol]
|
|
456
|
+
except Exception:
|
|
457
|
+
_asset_meta_cache[symbol] = (None, 18)
|
|
458
|
+
return _asset_meta_cache[symbol]
|
|
459
|
+
|
|
460
|
+
# ─── RedStone on-demand price wrapping ───────────────────────────────────────
|
|
461
|
+
# DegenPrime uses RedStone's on-demand model identically to DeltaPrime: signed price
|
|
462
|
+
# packages are fetched off-chain and APPENDED to the function calldata (after the
|
|
463
|
+
# normal ABI-encoded args). The solvency math parses them from the calldata tail,
|
|
464
|
+
# verifies the signatures, and aggregates by median. Without the payload these calls
|
|
465
|
+
# revert with 0xe7764c9e.
|
|
466
|
+
#
|
|
467
|
+
# Payload layout (matches @redstone-finance/evm-connector). Each signed data package:
|
|
468
|
+
# for each data point: symbol(bytes32) ++ value(uint256, scaled 1e8, big-endian)
|
|
469
|
+
# trailer: timestamp_ms(6) ++ dataPointValueByteSize(4)=32 ++ dataPointsCount(3)
|
|
470
|
+
# signature(65): r ++ s ++ v
|
|
471
|
+
# After all packages: dataPackagesCount(2) ++ unsignedMetadataSize(3)=0 ++ marker(9).
|
|
472
|
+
#
|
|
473
|
+
# The value MUST be reconstructed exactly as RedStone signed it:
|
|
474
|
+
# parseUnits(Number(value).toFixed(8), 8). Decimal(float(value)).quantize(1e-8,
|
|
475
|
+
# ROUND_HALF_UP) reproduces toFixed(8) byte-for-byte; the old int(round(value*1e8))
|
|
476
|
+
# path double-rounds and produces a body the contract ecrecovers wrong -> garbage
|
|
477
|
+
# signer -> SignerNotAuthorised. Verified on DeltaPrime; we keep the same fix here.
|
|
478
|
+
|
|
479
|
+
# Per-run cache for the RedStone gateway response - so a single summary call hits the
|
|
480
|
+
# gateway once instead of per-feed-symbol. Cleared implicitly when the process exits.
|
|
481
|
+
_redstone_gateway_cache = None
|
|
482
|
+
|
|
483
|
+
def _redstone_fetch_packages(use_cache: bool = True) -> dict:
|
|
484
|
+
"""Fetch the latest signed price packages from the RedStone gateway. Returns the
|
|
485
|
+
per-feed map: {feedSymbol: [package, ...]} with one package per signer. The per-run
|
|
486
|
+
cache lets repeat callers reuse the same snapshot - important for summary, where the
|
|
487
|
+
same payload backs getTotalValue / getDebt / getHealthRatio / isSolvent / getPrices."""
|
|
488
|
+
global _redstone_gateway_cache
|
|
489
|
+
if use_cache and _redstone_gateway_cache is not None:
|
|
490
|
+
return _redstone_gateway_cache
|
|
491
|
+
last_err = None
|
|
492
|
+
for gw in REDSTONE_GATEWAYS:
|
|
493
|
+
try:
|
|
494
|
+
r = requests.get(f"{gw}/data-packages/latest/{REDSTONE_DATA_SERVICE}", timeout=20)
|
|
495
|
+
if r.status_code == 200:
|
|
496
|
+
_redstone_gateway_cache = r.json()
|
|
497
|
+
return _redstone_gateway_cache
|
|
498
|
+
last_err = f"HTTP {r.status_code}"
|
|
499
|
+
except Exception as e:
|
|
500
|
+
last_err = e
|
|
501
|
+
raise RuntimeError(f"RedStone gateway fetch failed: {last_err}")
|
|
502
|
+
|
|
503
|
+
def _redstone_scaled_value(value) -> int:
|
|
504
|
+
"""Reconstruct the signed uint256 exactly as RedStone does: parseUnits(Number(value)
|
|
505
|
+
.toFixed(8), 8). See module docstring for why this is the only correct path."""
|
|
506
|
+
d = Decimal(float(value)).quantize(Decimal(1).scaleb(-REDSTONE_VALUE_DECIMALS),
|
|
507
|
+
rounding=ROUND_HALF_UP)
|
|
508
|
+
return int((d * (10 ** REDSTONE_VALUE_DECIMALS)).to_integral_value())
|
|
509
|
+
|
|
510
|
+
def _redstone_encode_package(pkg: dict) -> bytes:
|
|
511
|
+
"""Serialize one signed data package to the on-chain byte layout."""
|
|
512
|
+
data_points = pkg["dataPoints"]
|
|
513
|
+
ts = int(pkg["timestampMilliseconds"])
|
|
514
|
+
body = b""
|
|
515
|
+
for dp in data_points:
|
|
516
|
+
value_scaled = _redstone_scaled_value(dp["value"])
|
|
517
|
+
body += dp["dataFeedId"].encode().ljust(32, b"\x00") + value_scaled.to_bytes(32, "big")
|
|
518
|
+
body += ts.to_bytes(6, "big") + (32).to_bytes(4, "big") + len(data_points).to_bytes(3, "big")
|
|
519
|
+
return body + base64.b64decode(pkg["signature"])
|
|
520
|
+
|
|
521
|
+
def _redstone_package_signer(pkg: dict) -> str:
|
|
522
|
+
"""Recover a package's signer: ecrecover over keccak256(body) (no EIP-191 prefix),
|
|
523
|
+
where body is the encoded package minus its trailing 65-byte signature."""
|
|
524
|
+
body = _redstone_encode_package(pkg)[:-65]
|
|
525
|
+
sig = base64.b64decode(pkg["signature"])
|
|
526
|
+
r = int.from_bytes(sig[0:32], "big")
|
|
527
|
+
s = int.from_bytes(sig[32:64], "big")
|
|
528
|
+
rec_id = sig[64] - 27 if sig[64] >= 27 else sig[64]
|
|
529
|
+
return eth_keys.Signature(vrs=(rec_id, r, s)).recover_public_key_from_msg_hash(
|
|
530
|
+
Web3.keccak(body)).to_checksum_address()
|
|
531
|
+
|
|
532
|
+
def build_redstone_payload(symbols: list) -> bytes:
|
|
533
|
+
"""Build a RedStone calldata payload covering the given feed symbols. Recovers each
|
|
534
|
+
package's signer, keeps only RedStone's authorised set, then takes the first
|
|
535
|
+
REDSTONE_SIGNERS_THRESHOLD per feed (the contract needs that many unique authorised
|
|
536
|
+
signers per feed to aggregate a median). Filtering guards against the gateway ever
|
|
537
|
+
returning extra/standby signers and surfaces a clear error rather than an on-chain
|
|
538
|
+
revert."""
|
|
539
|
+
gateway = _redstone_fetch_packages()
|
|
540
|
+
packages = []
|
|
541
|
+
for sym in symbols:
|
|
542
|
+
feed_packages = gateway.get(sym)
|
|
543
|
+
if not feed_packages:
|
|
544
|
+
raise RuntimeError(f"RedStone gateway has no feed for '{sym}'")
|
|
545
|
+
authorised = [p for p in feed_packages
|
|
546
|
+
if _redstone_package_signer(p).lower() in REDSTONE_AUTHORISED_SIGNERS]
|
|
547
|
+
if len(authorised) < REDSTONE_SIGNERS_THRESHOLD:
|
|
548
|
+
raise RuntimeError(
|
|
549
|
+
f"RedStone feed '{sym}' has only {len(authorised)} authorised signers "
|
|
550
|
+
f"(of {len(feed_packages)} returned), need {REDSTONE_SIGNERS_THRESHOLD}")
|
|
551
|
+
for pkg in authorised[:REDSTONE_SIGNERS_THRESHOLD]:
|
|
552
|
+
packages.append(_redstone_encode_package(pkg))
|
|
553
|
+
payload = b"".join(packages)
|
|
554
|
+
payload += len(packages).to_bytes(2, "big")
|
|
555
|
+
payload += (0).to_bytes(3, "big")
|
|
556
|
+
payload += REDSTONE_MARKER
|
|
557
|
+
return payload
|
|
558
|
+
|
|
559
|
+
def degen_account_price_feeds(account) -> list:
|
|
560
|
+
"""RedStone feed symbols a solvency check on this account needs: ETH (Base's native
|
|
561
|
+
base-asset reference, the BaseOracle anchor), every owned asset that has a RedStone
|
|
562
|
+
feed, and every debt-registry asset that has one. Symbols without a RedStone feed
|
|
563
|
+
are skipped - the SolvencyFacet sources them on-chain from BaseOracle TWAP and the
|
|
564
|
+
payload doesn't need to cover them. Deduped, ETH first."""
|
|
565
|
+
feeds = ["ETH"]
|
|
566
|
+
for a in account.functions.getAllOwnedAssets().call():
|
|
567
|
+
sym = a.rstrip(b"\x00").decode(errors="replace")
|
|
568
|
+
if sym and sym in REDSTONE_AVAILABLE_FEEDS and sym not in feeds:
|
|
569
|
+
feeds.append(sym)
|
|
570
|
+
for name, _debt in account.functions.getDebts().call():
|
|
571
|
+
sym = name.rstrip(b"\x00").decode(errors="replace")
|
|
572
|
+
if sym and sym in REDSTONE_AVAILABLE_FEEDS and sym not in feeds:
|
|
573
|
+
feeds.append(sym)
|
|
574
|
+
return feeds
|
|
575
|
+
|
|
576
|
+
def redstone_view_call(w3, account, fn_name: str, payload: bytes, args: list = None):
|
|
577
|
+
"""Read-only call of a RedStone-gated view on the Degen Account. The signed price
|
|
578
|
+
payload is appended to the ABI-encoded calldata (same wrapping as a write tx), then
|
|
579
|
+
eth_call'd and the result decoded against the function's ABI. Used for the solvency
|
|
580
|
+
views (getHealthRatio/getTotalValue/getDebt/isSolvent, no args), which revert with
|
|
581
|
+
0xe7764c9e on a bare call. `payload` is reused across calls so the gateway is hit
|
|
582
|
+
once."""
|
|
583
|
+
data = account.encode_abi(fn_name, args=args or []) + payload.hex()
|
|
584
|
+
raw = w3.eth.call({"to": account.address, "data": data})
|
|
585
|
+
fn_abi = next(f for f in PRIME_ACCOUNT_ABI if f.get("name") == fn_name)
|
|
586
|
+
out_types = [o["type"] for o in fn_abi["outputs"]]
|
|
587
|
+
return w3.codec.decode(out_types, bytes(raw))
|
|
588
|
+
|
|
589
|
+
# ─── Commands ──────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
def cmd_pool_info(pool_name: str):
|
|
592
|
+
if pool_name == "all":
|
|
593
|
+
for name in POOLS:
|
|
594
|
+
cmd_pool_info(name)
|
|
595
|
+
print()
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
contract, cfg, w3 = get_pool_contract(pool_name)
|
|
599
|
+
p = cfg["proxy"][:12]
|
|
600
|
+
d = cfg["decimals"]
|
|
601
|
+
|
|
602
|
+
ts = contract.functions.totalSupply().call()
|
|
603
|
+
tb = contract.functions.totalBorrowed().call()
|
|
604
|
+
print(f"=== {cfg['symbol']} Pool ({p}...) ===")
|
|
605
|
+
print(f" Total Supply: {ts / 10**d:>14,.2f} {cfg['symbol']}")
|
|
606
|
+
print(f" Total Borrowed: {tb / 10**d:>14,.2f} {cfg['symbol']}")
|
|
607
|
+
util = tb / ts * 100 if ts > 0 else 0
|
|
608
|
+
print(f" Utilization: {util:>14.2f}%")
|
|
609
|
+
# getDepositRate / getBorrowingRate are 1e18-scaled annualised rates.
|
|
610
|
+
try:
|
|
611
|
+
dr = contract.functions.getDepositRate().call() / 1e18 * 100
|
|
612
|
+
br = contract.functions.getBorrowingRate().call() / 1e18 * 100
|
|
613
|
+
print(f" Deposit APR: {dr:>14.2f}%")
|
|
614
|
+
print(f" Borrow APR: {br:>14.2f}%")
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
617
|
+
# KuCoin doesn't trade cbBTC/cbDOGE/cbXRP directly - the cb-prefixed variants are
|
|
618
|
+
# Coinbase wrapped versions; fall back to the underlying ticker for the price probe.
|
|
619
|
+
price_sym = cfg["symbol"].replace("cb", "") if cfg["symbol"].startswith("cb") else cfg["symbol"]
|
|
620
|
+
price = token_price(price_sym)
|
|
621
|
+
if price:
|
|
622
|
+
print(f" Token Price: ${price:>13,.2f}")
|
|
623
|
+
print(f" TVL: ${ts / 10**d * price:>13,.2f}")
|
|
624
|
+
|
|
625
|
+
# Show the signer's pool deposit when a key is configured; pool-info should
|
|
626
|
+
# also work as a pure read-only command without one.
|
|
627
|
+
try:
|
|
628
|
+
acct = get_account()
|
|
629
|
+
except RuntimeError:
|
|
630
|
+
return
|
|
631
|
+
my_bal = contract.functions.balanceOf(acct.address).call()
|
|
632
|
+
if my_bal > 0:
|
|
633
|
+
print(f" My Deposit: {my_bal / 10**d:.4f} {cfg['symbol']}")
|
|
634
|
+
|
|
635
|
+
def cmd_my_positions():
|
|
636
|
+
acct = get_account()
|
|
637
|
+
w3 = get_w3()
|
|
638
|
+
print(f"Wallet: {acct.address}")
|
|
639
|
+
|
|
640
|
+
# Wallet ETH (native Base asset, used for gas).
|
|
641
|
+
eth = w3.eth.get_balance(acct.address) / 1e18
|
|
642
|
+
print(f"ETH: {eth:.6f}")
|
|
643
|
+
|
|
644
|
+
for name, cfg in POOLS.items():
|
|
645
|
+
try:
|
|
646
|
+
contract, _, _ = get_pool_contract(name)
|
|
647
|
+
token_addr = Web3.to_checksum_address(cfg["token"])
|
|
648
|
+
token = w3.eth.contract(address=token_addr, abi=ERC20_ABI)
|
|
649
|
+
bal = token.functions.balanceOf(acct.address).call()
|
|
650
|
+
if bal > 0:
|
|
651
|
+
print(f" Wallet {cfg['symbol']}: {bal / 10**cfg['decimals']:.4f}")
|
|
652
|
+
|
|
653
|
+
pool_bal = contract.functions.balanceOf(acct.address).call()
|
|
654
|
+
if pool_bal > 0:
|
|
655
|
+
print(f" Pool Deposit {cfg['symbol']}: {pool_bal / 10**cfg['decimals']:.4f}")
|
|
656
|
+
|
|
657
|
+
borrowed = contract.functions.getBorrowed(acct.address).call()
|
|
658
|
+
if borrowed > 0:
|
|
659
|
+
print(f" Borrowed {cfg['symbol']}: {borrowed / 10**cfg['decimals']:.4f}")
|
|
660
|
+
|
|
661
|
+
except Exception as e:
|
|
662
|
+
print(f" {name}: {e}")
|
|
663
|
+
|
|
664
|
+
try:
|
|
665
|
+
pa = get_prime_account(w3, acct.address)
|
|
666
|
+
if pa:
|
|
667
|
+
print(f"\nDegen Account: {pa}")
|
|
668
|
+
pa_eth = w3.eth.get_balance(Web3.to_checksum_address(pa)) / 1e18
|
|
669
|
+
print(f" ETH balance: {pa_eth:.6f}")
|
|
670
|
+
else:
|
|
671
|
+
print("\nNo Degen Account yet. Create with: degenprime create-account --execute")
|
|
672
|
+
except Exception as e:
|
|
673
|
+
print(f"\nDegen Account lookup failed: {e}")
|
|
674
|
+
|
|
675
|
+
def cmd_deposit(pool_name: str, amount: float, execute: bool = False):
|
|
676
|
+
contract, cfg, w3 = get_pool_contract(pool_name)
|
|
677
|
+
acct = get_account()
|
|
678
|
+
amount_wei = int(amount * 10**cfg["decimals"])
|
|
679
|
+
print(f"Wallet: {acct.address}")
|
|
680
|
+
|
|
681
|
+
if not execute:
|
|
682
|
+
print(f"Preview: Deposit {amount} {cfg['symbol']} into {pool_name.upper()} pool")
|
|
683
|
+
print("Run with --execute to broadcast")
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
if cfg["native"]:
|
|
687
|
+
# Native ETH path: pool.deposit(amount) with msg.value == amount (the pool wraps
|
|
688
|
+
# ETH -> WETH internally). Same pattern as DeltaPrime's wavax pool.
|
|
689
|
+
tx = contract.functions.deposit(amount_wei).build_transaction({
|
|
690
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
691
|
+
"gas": 300000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID, "value": amount_wei,
|
|
692
|
+
})
|
|
693
|
+
signed = acct.sign_transaction(tx)
|
|
694
|
+
else:
|
|
695
|
+
token = w3.eth.contract(address=Web3.to_checksum_address(cfg["token"]), abi=ERC20_ABI)
|
|
696
|
+
app_tx = token.functions.approve(Web3.to_checksum_address(cfg["proxy"]), amount_wei).build_transaction({
|
|
697
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
698
|
+
"gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
699
|
+
})
|
|
700
|
+
signed_app = acct.sign_transaction(app_tx)
|
|
701
|
+
w3.eth.send_raw_transaction(signed_app.raw_transaction)
|
|
702
|
+
|
|
703
|
+
dep_tx = contract.functions.deposit(amount_wei).build_transaction({
|
|
704
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
705
|
+
"gas": 300000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
706
|
+
})
|
|
707
|
+
signed = acct.sign_transaction(dep_tx)
|
|
708
|
+
|
|
709
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
710
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
|
711
|
+
ok = receipt["status"] == 1
|
|
712
|
+
print(f"{'✓' if ok else '✗'} Deposit {amount} {cfg['symbol']} {'confirmed' if ok else 'failed'}")
|
|
713
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
714
|
+
|
|
715
|
+
def cmd_withdraw(pool_name: str, amount: float, execute: bool = False):
|
|
716
|
+
"""Pool-side (LENDER) withdraw. Instant, no time-lock - this is the savings-pool
|
|
717
|
+
side. The Degen Account collateral withdraw flow is separate (withdraw-collateral
|
|
718
|
+
-> 24h delay -> execute-withdrawal).
|
|
719
|
+
|
|
720
|
+
Always calls the pool's `withdraw()` (returns wrapped tokens to the wallet, even
|
|
721
|
+
for the WETH pool). The pool also exposes `withdrawNativeToken(uint256)` that
|
|
722
|
+
would unwrap to native ETH directly; a future --native flag could opt into it."""
|
|
723
|
+
contract, cfg, w3 = get_pool_contract(pool_name)
|
|
724
|
+
acct = get_account()
|
|
725
|
+
amount_wei = int(amount * 10**cfg["decimals"])
|
|
726
|
+
print(f"Wallet: {acct.address}")
|
|
727
|
+
|
|
728
|
+
if not execute:
|
|
729
|
+
print(f"Preview: Withdraw {amount} {cfg['symbol']} from {pool_name.upper()} pool")
|
|
730
|
+
print(" Instant withdrawal from the lending pool (no time-lock).")
|
|
731
|
+
print("Run with --execute to broadcast")
|
|
732
|
+
return
|
|
733
|
+
|
|
734
|
+
tx = contract.functions.withdraw(amount_wei).build_transaction({
|
|
735
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
736
|
+
"gas": 300000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
737
|
+
})
|
|
738
|
+
signed = acct.sign_transaction(tx)
|
|
739
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
740
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
|
741
|
+
ok = receipt["status"] == 1
|
|
742
|
+
print(f"{'✓' if ok else '✗'} Withdraw {amount} {cfg['symbol']} {'confirmed' if ok else 'failed'}")
|
|
743
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
744
|
+
|
|
745
|
+
# ─── Degen Account commands ──────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
def _asset_decimals(w3, symbol: str) -> int:
|
|
748
|
+
"""bytes32 asset symbol -> decimals. Pool symbols are static; non-pool collateral
|
|
749
|
+
(memecoins, LSTs, etc.) resolves via the TokenManager + ERC20.decimals(). Fall back
|
|
750
|
+
to 18 if even that fails."""
|
|
751
|
+
_addr, dec = _asset_meta(w3, symbol)
|
|
752
|
+
return dec
|
|
753
|
+
|
|
754
|
+
def cmd_create_account(execute: bool = False, fund_pool: str = None, fund_amount: float = None):
|
|
755
|
+
"""Create a Degen Account. With fund_pool/fund_amount, create and fund in one
|
|
756
|
+
tx via SmartLoansFactory.createAndFundLoan(bytes32 asset, amount) - ERC20 only,
|
|
757
|
+
and the factory pulls the asset via transferFrom so it needs a prior approve to
|
|
758
|
+
the factory. Without fund args, plain createLoan() makes an empty account."""
|
|
759
|
+
w3 = get_w3()
|
|
760
|
+
acct = get_account()
|
|
761
|
+
print(f"Wallet: {acct.address}")
|
|
762
|
+
existing = get_prime_account(w3, acct.address)
|
|
763
|
+
if existing:
|
|
764
|
+
print(f"Degen Account already exists: {existing}")
|
|
765
|
+
print("Nothing to create. Fund it with: degenprime fund --pool <p> --amount <n> --execute")
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
funding = fund_pool is not None and fund_amount is not None
|
|
769
|
+
cfg = POOLS[fund_pool] if funding else None
|
|
770
|
+
if funding and cfg["native"]:
|
|
771
|
+
print("createAndFundLoan is ERC20-only - it cannot wrap native ETH.")
|
|
772
|
+
print("For an ETH-funded account: create-account --execute, then")
|
|
773
|
+
print(" fund --pool weth --amount <n> --execute (uses depositNativeToken()).")
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
factory = get_factory_contract(w3)
|
|
777
|
+
factory_cs = Web3.to_checksum_address(FACTORY_PROXY)
|
|
778
|
+
|
|
779
|
+
if not execute:
|
|
780
|
+
print(f"Preview: Create a new Degen Account for {acct.address}")
|
|
781
|
+
if funding:
|
|
782
|
+
symbol = cfg["symbol"]
|
|
783
|
+
amount_wei = int(fund_amount * 10**cfg["decimals"])
|
|
784
|
+
print(f" Factory: {FACTORY_PROXY} (SmartLoansFactory.createAndFundLoan())")
|
|
785
|
+
print(f" Approves the factory to spend {fund_amount} {symbol}, then")
|
|
786
|
+
print(f" calls createAndFundLoan(bytes32 '{symbol}', {amount_wei}) - creates + funds in one go.")
|
|
787
|
+
print(" Wallet must hold enough of the asset.")
|
|
788
|
+
else:
|
|
789
|
+
print(f" Factory: {FACTORY_PROXY} (SmartLoansFactory.createLoan())")
|
|
790
|
+
print(" Creates an empty account; fund it afterwards before borrowing.")
|
|
791
|
+
print("Run with --execute to broadcast")
|
|
792
|
+
return
|
|
793
|
+
|
|
794
|
+
if funding:
|
|
795
|
+
symbol = cfg["symbol"]
|
|
796
|
+
amount_wei = int(fund_amount * 10**cfg["decimals"])
|
|
797
|
+
token = w3.eth.contract(address=Web3.to_checksum_address(cfg["token"]), abi=ERC20_ABI)
|
|
798
|
+
app_tx = token.functions.approve(factory_cs, amount_wei).build_transaction({
|
|
799
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
800
|
+
"gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
801
|
+
})
|
|
802
|
+
signed_app = acct.sign_transaction(app_tx)
|
|
803
|
+
w3.eth.send_raw_transaction(signed_app.raw_transaction)
|
|
804
|
+
|
|
805
|
+
tx = factory.functions.createAndFundLoan(asset_b32(symbol), amount_wei).build_transaction({
|
|
806
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
807
|
+
"gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
808
|
+
})
|
|
809
|
+
else:
|
|
810
|
+
tx = factory.functions.createLoan().build_transaction({
|
|
811
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
812
|
+
"gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
813
|
+
})
|
|
814
|
+
signed = acct.sign_transaction(tx)
|
|
815
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
816
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
817
|
+
ok = receipt["status"] == 1
|
|
818
|
+
label = "Create+fund Degen Account" if funding else "Create Degen Account"
|
|
819
|
+
print(f"{'✓' if ok else '✗'} {label} {'confirmed' if ok else 'failed'}")
|
|
820
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
821
|
+
if ok:
|
|
822
|
+
# getLoansForOwner can lag a beat behind the receipt; poll briefly so we
|
|
823
|
+
# print the new account address instead of None right after creation.
|
|
824
|
+
pa = None
|
|
825
|
+
for _ in range(6):
|
|
826
|
+
pa = get_prime_account(w3, acct.address)
|
|
827
|
+
if pa:
|
|
828
|
+
break
|
|
829
|
+
time.sleep(2)
|
|
830
|
+
if pa:
|
|
831
|
+
print(f" Degen Account: {pa}")
|
|
832
|
+
else:
|
|
833
|
+
print(" Degen Account: created - getLoansForOwner not propagated yet, run 'my-positions' shortly.")
|
|
834
|
+
|
|
835
|
+
def cmd_fund(pool_name: str, amount: float, execute: bool = False):
|
|
836
|
+
"""Fund collateral from the EOA wallet into its Degen Account.
|
|
837
|
+
|
|
838
|
+
ERC20 assets: approve the Degen Account to spend the token, then call
|
|
839
|
+
fund(bytes32 asset, amount) on it. Native ETH (weth pool): call the
|
|
840
|
+
payable depositNativeToken() and send ETH as msg.value - the account
|
|
841
|
+
wraps ETH->WETH internally, so no token approve is needed.
|
|
842
|
+
"""
|
|
843
|
+
cfg = POOLS[pool_name]
|
|
844
|
+
w3 = get_w3()
|
|
845
|
+
acct = get_account()
|
|
846
|
+
print(f"Wallet: {acct.address}")
|
|
847
|
+
pa = get_prime_account(w3, acct.address)
|
|
848
|
+
if not pa:
|
|
849
|
+
print("No Degen Account. Create one first: degenprime create-account --execute")
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
symbol = pool_to_asset_symbol(pool_name)
|
|
853
|
+
amount_wei = int(amount * 10**cfg["decimals"])
|
|
854
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
855
|
+
|
|
856
|
+
if not execute:
|
|
857
|
+
print(f"Preview: Fund {amount} {symbol} into Degen Account {pa}")
|
|
858
|
+
if cfg["native"]:
|
|
859
|
+
print(f" Native ETH: calls depositNativeToken() with value={amount_wei} wei")
|
|
860
|
+
print(" Wraps ETH->WETH inside the account; no token approval needed.")
|
|
861
|
+
else:
|
|
862
|
+
print(f" Approves {pa} to spend {amount} {symbol}, then calls fund(bytes32 '{symbol}', {amount_wei})")
|
|
863
|
+
print(" Wallet must hold enough of the asset.")
|
|
864
|
+
print("Run with --execute to broadcast")
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
868
|
+
if cfg["native"]:
|
|
869
|
+
tx = account.functions.depositNativeToken().build_transaction({
|
|
870
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
871
|
+
"gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID, "value": amount_wei,
|
|
872
|
+
})
|
|
873
|
+
signed = acct.sign_transaction(tx)
|
|
874
|
+
else:
|
|
875
|
+
token = w3.eth.contract(address=Web3.to_checksum_address(cfg["token"]), abi=ERC20_ABI)
|
|
876
|
+
app_tx = token.functions.approve(pa_cs, amount_wei).build_transaction({
|
|
877
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
878
|
+
"gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
879
|
+
})
|
|
880
|
+
signed_app = acct.sign_transaction(app_tx)
|
|
881
|
+
w3.eth.send_raw_transaction(signed_app.raw_transaction)
|
|
882
|
+
|
|
883
|
+
fund_tx = account.functions.fund(asset_b32(symbol), amount_wei).build_transaction({
|
|
884
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
885
|
+
"gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
886
|
+
})
|
|
887
|
+
signed = acct.sign_transaction(fund_tx)
|
|
888
|
+
|
|
889
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
890
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
891
|
+
ok = receipt["status"] == 1
|
|
892
|
+
print(f"{'✓' if ok else '✗'} Fund {amount} {symbol} {'confirmed' if ok else 'failed'}")
|
|
893
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
894
|
+
return ok
|
|
895
|
+
|
|
896
|
+
def _prices_usd(w3, account, symbols: list, payload: bytes) -> dict:
|
|
897
|
+
"""Best-effort per-symbol USD price map via the RedStone-gated getPrices view
|
|
898
|
+
(1e8-scaled). Reuses an already-built `payload`; returns {symbol: float}. Symbols
|
|
899
|
+
without a RedStone feed are filtered out before the call - the SolvencyFacet
|
|
900
|
+
reverts on a getPrices request for a symbol whose feed isn't in the payload."""
|
|
901
|
+
syms = [s for s in dict.fromkeys(symbols) if s and s in REDSTONE_AVAILABLE_FEEDS]
|
|
902
|
+
if not syms:
|
|
903
|
+
return {}
|
|
904
|
+
try:
|
|
905
|
+
raw = redstone_view_call(w3, account, "getPrices", payload,
|
|
906
|
+
args=[[asset_b32(s) for s in syms]])[0]
|
|
907
|
+
return {s: raw[i] / 1e8 for i, s in enumerate(syms)}
|
|
908
|
+
except Exception:
|
|
909
|
+
return {}
|
|
910
|
+
|
|
911
|
+
def cmd_summary():
|
|
912
|
+
"""Read-only Degen Account view: in-account collateral, debts, and live
|
|
913
|
+
RedStone-gated solvency (getTotalValue/getDebt/getHealthRatio/isSolvent). Falls
|
|
914
|
+
back to balances-only if the RedStone gateway is unreachable or a view reverts.
|
|
915
|
+
Note: per-asset USD is best-effort - only symbols with a RedStone primary-prod
|
|
916
|
+
feed are priced here. Symbols sourced on-chain from BaseOracle TWAP show as
|
|
917
|
+
balance-only (the SolvencyFacet still values them for the total/debt figures)."""
|
|
918
|
+
w3 = get_w3()
|
|
919
|
+
acct = get_account()
|
|
920
|
+
pa = get_prime_account(w3, acct.address)
|
|
921
|
+
print(f"Wallet: {acct.address}")
|
|
922
|
+
if not pa:
|
|
923
|
+
print("No Degen Account yet. Create one with: degenprime create-account --execute")
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
print(f"Degen Account: {pa}")
|
|
927
|
+
pa_eth = w3.eth.get_balance(pa) / 1e18
|
|
928
|
+
print(f" Native ETH (gas): {pa_eth:.6f}")
|
|
929
|
+
|
|
930
|
+
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
931
|
+
owned = [a.rstrip(b"\x00").decode(errors="replace") for a in account.functions.getAllOwnedAssets().call()]
|
|
932
|
+
supplied = []
|
|
933
|
+
for sym in owned:
|
|
934
|
+
bal = account.functions.getBalance(asset_b32(sym)).call()
|
|
935
|
+
supplied.append({"symbol": sym, "raw": bal, "decimals": _asset_decimals(w3, sym)})
|
|
936
|
+
borrowed = []
|
|
937
|
+
for n, v in account.functions.getDebts().call():
|
|
938
|
+
sym = n.rstrip(b"\x00").decode(errors="replace")
|
|
939
|
+
if v > 0:
|
|
940
|
+
borrowed.append({"symbol": sym, "raw": v, "decimals": _asset_decimals(w3, sym)})
|
|
941
|
+
|
|
942
|
+
# Solvency views (SolvencyFacet) are RedStone-gated: they revert (0xe7764c9e)
|
|
943
|
+
# without signed price calldata appended. Fetch a fresh RedStone payload covering
|
|
944
|
+
# every feed the solvency math touches (RedStone-feed symbols only - others come
|
|
945
|
+
# from BaseOracle on-chain) and eth_call the views with it appended. No tx.
|
|
946
|
+
solvency = {"total": None, "debt": None, "ratio": None, "solvent": None, "error": None, "prices": {}}
|
|
947
|
+
try:
|
|
948
|
+
feeds = degen_account_price_feeds(account)
|
|
949
|
+
payload = build_redstone_payload(feeds)
|
|
950
|
+
solvency["total"] = redstone_view_call(w3, account, "getTotalValue", payload)[0] / 1e18
|
|
951
|
+
solvency["debt"] = redstone_view_call(w3, account, "getDebt", payload)[0] / 1e18
|
|
952
|
+
ratio = redstone_view_call(w3, account, "getHealthRatio", payload)[0] / 1e18
|
|
953
|
+
# With negligible debt the ratio is astronomically large (e.g. 1e59) - render
|
|
954
|
+
# that as None and show ">1000" rather than a junk number.
|
|
955
|
+
solvency["ratio"] = None if ratio > 1000 else ratio
|
|
956
|
+
solvency["solvent"] = bool(redstone_view_call(w3, account, "isSolvent", payload)[0])
|
|
957
|
+
solvency["prices"] = _prices_usd(w3, account, [r["symbol"] for r in supplied + borrowed], payload)
|
|
958
|
+
except Exception as e:
|
|
959
|
+
solvency["error"] = type(e).__name__
|
|
960
|
+
|
|
961
|
+
print(" Assets:")
|
|
962
|
+
if supplied:
|
|
963
|
+
for r in supplied:
|
|
964
|
+
usd = solvency["prices"].get(r["symbol"])
|
|
965
|
+
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
966
|
+
print(f" {r['symbol']:<8} {r['raw'] / 10**r['decimals']:,.6f}{usd_str}")
|
|
967
|
+
else:
|
|
968
|
+
print(" (none)")
|
|
969
|
+
|
|
970
|
+
print(" Debts:")
|
|
971
|
+
if borrowed:
|
|
972
|
+
for r in borrowed:
|
|
973
|
+
usd = solvency["prices"].get(r["symbol"])
|
|
974
|
+
usd_str = f" (~${r['raw'] / 10**r['decimals'] * usd:,.2f})" if usd is not None else ""
|
|
975
|
+
print(f" {r['symbol']:<8} {r['raw'] / 10**r['decimals']:,.6f}{usd_str}")
|
|
976
|
+
else:
|
|
977
|
+
print(" (none)")
|
|
978
|
+
|
|
979
|
+
if solvency["error"] is None:
|
|
980
|
+
print(f" Total value: ${solvency['total']:,.2f}")
|
|
981
|
+
print(f" Debt: ${solvency['debt']:,.2f}")
|
|
982
|
+
ratio_str = ">1000.00 (negligible debt)" if solvency["ratio"] is None else f"{solvency['ratio']:.4f}"
|
|
983
|
+
print(f" Health ratio: {ratio_str} (>1.0 = solvent)")
|
|
984
|
+
print(f" Solvent: {'yes' if solvency['solvent'] else 'NO - liquidatable'}")
|
|
985
|
+
else:
|
|
986
|
+
print(f" Health/solvency: RedStone fetch/call failed ({solvency['error']}); showing balances only")
|
|
987
|
+
|
|
988
|
+
def cmd_borrow(pool_name: str, amount: float, execute: bool = False):
|
|
989
|
+
cfg = POOLS[pool_name]
|
|
990
|
+
w3 = get_w3()
|
|
991
|
+
acct = get_account()
|
|
992
|
+
print(f"Wallet: {acct.address}")
|
|
993
|
+
pa = get_prime_account(w3, acct.address)
|
|
994
|
+
if not pa:
|
|
995
|
+
print("No Degen Account. Create one first: degenprime create-account --execute")
|
|
996
|
+
return
|
|
997
|
+
|
|
998
|
+
symbol = pool_to_asset_symbol(pool_name)
|
|
999
|
+
amount_wei = int(amount * 10**cfg["decimals"])
|
|
1000
|
+
if not execute:
|
|
1001
|
+
print(f"Preview: Borrow {amount} {symbol} into Degen Account {pa}")
|
|
1002
|
+
print(f" Calls borrow(bytes32 '{symbol}', {amount_wei}) on the Degen Account")
|
|
1003
|
+
print(" Requires sufficient collateral funded into the account.")
|
|
1004
|
+
print("Run with --execute to broadcast")
|
|
1005
|
+
return
|
|
1006
|
+
|
|
1007
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1008
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1009
|
+
# borrow has remainsSolvent -> needs RedStone price payload appended to calldata.
|
|
1010
|
+
feeds = degen_account_price_feeds(account)
|
|
1011
|
+
if symbol in REDSTONE_AVAILABLE_FEEDS and symbol not in feeds:
|
|
1012
|
+
feeds.append(symbol)
|
|
1013
|
+
payload = build_redstone_payload(feeds)
|
|
1014
|
+
base_calldata = account.encode_abi("borrow", args=[asset_b32(symbol), amount_wei])
|
|
1015
|
+
data = base_calldata + payload.hex()
|
|
1016
|
+
tx = {
|
|
1017
|
+
"from": acct.address, "to": pa_cs, "data": data,
|
|
1018
|
+
"nonce": w3.eth.get_transaction_count(acct.address),
|
|
1019
|
+
"gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1020
|
+
}
|
|
1021
|
+
signed = acct.sign_transaction(tx)
|
|
1022
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1023
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1024
|
+
ok = receipt["status"] == 1
|
|
1025
|
+
print(f"{'✓' if ok else '✗'} Borrow {amount} {symbol} {'confirmed' if ok else 'failed'}")
|
|
1026
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1027
|
+
return ok
|
|
1028
|
+
|
|
1029
|
+
def cmd_repay(pool_name: str, amount: float, execute: bool = False):
|
|
1030
|
+
"""Repay from the Degen Account's in-account balance.
|
|
1031
|
+
|
|
1032
|
+
On the DeltaPrime/DegenPrime shared facet, repay's internal path runs
|
|
1033
|
+
`_isSolvent()` over proxyDelegateCalldata, so the tx must carry a RedStone price
|
|
1034
|
+
payload appended to the calldata even though the function signature isn't directly
|
|
1035
|
+
`remainsSolvent`-modified. Append it (mirrors the DeltaPrime repay fix).
|
|
1036
|
+
|
|
1037
|
+
The facet's repay reverts if amount > debt OR amount > in-account balance, so we
|
|
1038
|
+
cap to min(requested, debt, in_account) for clean handling of overshoots."""
|
|
1039
|
+
cfg = POOLS[pool_name]
|
|
1040
|
+
w3 = get_w3()
|
|
1041
|
+
acct = get_account()
|
|
1042
|
+
print(f"Wallet: {acct.address}")
|
|
1043
|
+
pa = get_prime_account(w3, acct.address)
|
|
1044
|
+
if not pa:
|
|
1045
|
+
print("No Degen Account. Create one first: degenprime create-account --execute")
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
symbol = pool_to_asset_symbol(pool_name)
|
|
1049
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1050
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1051
|
+
pool, _, _ = get_pool_contract(pool_name)
|
|
1052
|
+
requested_wei = int(amount * 10**cfg["decimals"])
|
|
1053
|
+
debt_wei = pool.functions.getBorrowed(pa_cs).call()
|
|
1054
|
+
in_acct_wei = account.functions.getBalance(asset_b32(symbol)).call()
|
|
1055
|
+
if debt_wei == 0:
|
|
1056
|
+
print(f"No {symbol} debt to repay on Degen Account {pa}.")
|
|
1057
|
+
return
|
|
1058
|
+
amount_wei = min(requested_wei, debt_wei, in_acct_wei)
|
|
1059
|
+
if amount_wei == 0:
|
|
1060
|
+
print(f"Repay {amount} {symbol}: in-account {symbol} balance is 0 - "
|
|
1061
|
+
f"swap into {symbol} first (e.g. degenprime swap --to {symbol} --amount N --execute).")
|
|
1062
|
+
return
|
|
1063
|
+
cap_notes = []
|
|
1064
|
+
if amount_wei < requested_wei:
|
|
1065
|
+
if in_acct_wei < min(requested_wei, debt_wei):
|
|
1066
|
+
cap_notes.append(f"in-account {symbol} only {in_acct_wei / 10**cfg['decimals']:.6f}")
|
|
1067
|
+
if debt_wei < requested_wei:
|
|
1068
|
+
cap_notes.append(f"debt only {debt_wei / 10**cfg['decimals']:.6f} {symbol}")
|
|
1069
|
+
|
|
1070
|
+
if not execute:
|
|
1071
|
+
print(f"Preview: Repay {amount_wei / 10**cfg['decimals']:.6f} {symbol} from Degen Account {pa}")
|
|
1072
|
+
if cap_notes:
|
|
1073
|
+
print(f" Capped from requested {amount}: {'; '.join(cap_notes)}")
|
|
1074
|
+
print(f" Calls repay(bytes32 '{symbol}', {amount_wei}) on the Degen Account")
|
|
1075
|
+
print(f" Current debt: {debt_wei / 10**cfg['decimals']:.6f} {symbol} | "
|
|
1076
|
+
f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol}")
|
|
1077
|
+
if in_acct_wei < debt_wei:
|
|
1078
|
+
shortfall = (debt_wei - in_acct_wei) / 10**cfg['decimals']
|
|
1079
|
+
print(f" Note: in-account < debt by {shortfall:.6f} {symbol} - "
|
|
1080
|
+
f"swap into {symbol} first to close the position fully.")
|
|
1081
|
+
print("Run with --execute to broadcast")
|
|
1082
|
+
return
|
|
1083
|
+
|
|
1084
|
+
if cap_notes:
|
|
1085
|
+
print(f" Capped requested {amount} {symbol} to {amount_wei / 10**cfg['decimals']:.6f} "
|
|
1086
|
+
f"({'; '.join(cap_notes)}).")
|
|
1087
|
+
# repay's internal _isSolvent uses proxyDelegateCalldata -> needs RedStone payload
|
|
1088
|
+
feeds = degen_account_price_feeds(account)
|
|
1089
|
+
if symbol not in feeds and symbol in REDSTONE_AVAILABLE_FEEDS:
|
|
1090
|
+
feeds.append(symbol)
|
|
1091
|
+
payload = build_redstone_payload(feeds)
|
|
1092
|
+
base_calldata = account.encode_abi("repay", args=[asset_b32(symbol), amount_wei])
|
|
1093
|
+
data = base_calldata + payload.hex()
|
|
1094
|
+
tx = {
|
|
1095
|
+
"from": acct.address, "to": pa_cs, "data": data,
|
|
1096
|
+
"nonce": w3.eth.get_transaction_count(acct.address),
|
|
1097
|
+
"gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1098
|
+
}
|
|
1099
|
+
signed = acct.sign_transaction(tx)
|
|
1100
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1101
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1102
|
+
ok = receipt["status"] == 1
|
|
1103
|
+
repaid = amount_wei / 10**cfg['decimals']
|
|
1104
|
+
print(f"{'✓' if ok else '✗'} Repay {repaid:.6f} {symbol} {'confirmed' if ok else 'failed'}")
|
|
1105
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1106
|
+
|
|
1107
|
+
# ─── ParaSwap / Velora route ─────────────────────────────────────────────────
|
|
1108
|
+
# The Degen Account already holds the funds, so the facet (not the EOA) approves the
|
|
1109
|
+
# Augustus router and executes. We only build the API calldata with the Degen Account
|
|
1110
|
+
# as the swapper + receiver, then hand its (selector, data) to paraSwapV6 /
|
|
1111
|
+
# swapDebtParaSwap.
|
|
1112
|
+
|
|
1113
|
+
# Map account-side bytes32 symbols to (token_address, decimals) for the swap and
|
|
1114
|
+
# swap-debt paths. Pool symbols are pre-baked here; non-pool symbols (memecoin
|
|
1115
|
+
# collateral) resolve dynamically via _asset_meta at the swap site.
|
|
1116
|
+
SWAP_ASSETS = {cfg["symbol"]: {"token": cfg["token"], "decimals": cfg["decimals"]}
|
|
1117
|
+
for cfg in POOLS.values()}
|
|
1118
|
+
|
|
1119
|
+
def _swap_asset_meta(w3, symbol: str):
|
|
1120
|
+
"""Resolve a swap-side symbol to {token, decimals}. Falls back to TokenManager for
|
|
1121
|
+
non-pool collateral (memecoins). Returns None if the asset is unknown."""
|
|
1122
|
+
if symbol in SWAP_ASSETS:
|
|
1123
|
+
return SWAP_ASSETS[symbol]
|
|
1124
|
+
addr, dec = _asset_meta(w3, symbol)
|
|
1125
|
+
if addr is None:
|
|
1126
|
+
return None
|
|
1127
|
+
return {"token": addr, "decimals": dec}
|
|
1128
|
+
|
|
1129
|
+
def _paraswap_price_route(src_token, src_dec, dest_token, dest_dec, amount_in_wei, user_addr):
|
|
1130
|
+
"""ParaSwap /prices on Base (network=8453, v6.2). Returns the priceRoute dict for a
|
|
1131
|
+
SELL of amount_in_wei src->dest. The priceRoute is passed verbatim to /transactions.
|
|
1132
|
+
excludeContractMethods is hard-coded to keep ParaSwap from picking a router method
|
|
1133
|
+
the facet can't decode (multiSwap/megaSwap/protected* etc.)."""
|
|
1134
|
+
params = {
|
|
1135
|
+
"srcToken": src_token, "srcDecimals": src_dec,
|
|
1136
|
+
"destToken": dest_token, "destDecimals": dest_dec,
|
|
1137
|
+
"amount": str(amount_in_wei), "side": "SELL",
|
|
1138
|
+
"network": CHAIN_ID, "version": "6.2", "userAddress": user_addr,
|
|
1139
|
+
"excludeContractMethods": "multiSwap,megaSwap,protectedMultiSwap,protectedMegaSwap,protectedSimpleSwap,simpleSwap",
|
|
1140
|
+
}
|
|
1141
|
+
r = requests.get(f"{PARASWAP_API}/prices", params=params,
|
|
1142
|
+
headers={"Accept": "application/json"}, timeout=20)
|
|
1143
|
+
d = r.json()
|
|
1144
|
+
pr = d.get("priceRoute")
|
|
1145
|
+
if not pr:
|
|
1146
|
+
raise RuntimeError(f"ParaSwap /prices returned no route: {d.get('error', d)}")
|
|
1147
|
+
return pr
|
|
1148
|
+
|
|
1149
|
+
def _paraswap_build_tx(price_route, src_token, src_dec, dest_token, dest_dec,
|
|
1150
|
+
amount_in_wei, slippage_pct, user_addr):
|
|
1151
|
+
"""ParaSwap /transactions on Base. Builds the Augustus calldata with the Degen
|
|
1152
|
+
Account as userAddress + receiver. partner='paraswap' makes the encoded
|
|
1153
|
+
partnerAndFee resolve to partner=0/fee=0, which the facet requires."""
|
|
1154
|
+
body = {
|
|
1155
|
+
"srcToken": src_token, "srcDecimals": src_dec,
|
|
1156
|
+
"destToken": dest_token, "destDecimals": dest_dec,
|
|
1157
|
+
"srcAmount": str(amount_in_wei),
|
|
1158
|
+
"slippage": int(round(slippage_pct * 100)), # bps
|
|
1159
|
+
"priceRoute": price_route,
|
|
1160
|
+
"userAddress": user_addr,
|
|
1161
|
+
"receiver": user_addr,
|
|
1162
|
+
"partner": "paraswap",
|
|
1163
|
+
}
|
|
1164
|
+
# ignoreChecks: the swapper is the Degen Account (a contract that holds no funds at
|
|
1165
|
+
# build time and hasn't approved Augustus yet - the facet does that mid-tx), so the
|
|
1166
|
+
# API's balance/allowance pre-checks would reject an otherwise valid build.
|
|
1167
|
+
r = requests.post(f"{PARASWAP_API}/transactions/{CHAIN_ID}?ignoreChecks=true&ignoreGasEstimate=true",
|
|
1168
|
+
json=body, headers={"Accept": "application/json"}, timeout=20)
|
|
1169
|
+
d = r.json()
|
|
1170
|
+
if "data" not in d:
|
|
1171
|
+
raise RuntimeError(f"ParaSwap /transactions returned no calldata: {d.get('error', d)}")
|
|
1172
|
+
return d
|
|
1173
|
+
|
|
1174
|
+
def _paraswap_decode_and_check(selector_hex, data_bytes, src_token, dest_token, expected_from, pa_cs):
|
|
1175
|
+
"""Mirror the facet's decodeParaSwapData + validateSwapParameters on the built
|
|
1176
|
+
calldata so a preview fails loud here rather than reverting on-chain. Returns the
|
|
1177
|
+
decoded (executor, src, dest, fromAmount, toAmount) for display."""
|
|
1178
|
+
if selector_hex not in PARASWAP_SUPPORTED_SELECTORS:
|
|
1179
|
+
raise RuntimeError(f"ParaSwap returned method {selector_hex}, which the facet does not "
|
|
1180
|
+
f"decode (supported: {', '.join(sorted(PARASWAP_SUPPORTED_SELECTORS))}). "
|
|
1181
|
+
"Refusing.")
|
|
1182
|
+
if len(data_bytes) < 288:
|
|
1183
|
+
raise RuntimeError(f"ParaSwap calldata body too short ({len(data_bytes)} bytes, need >=288).")
|
|
1184
|
+
|
|
1185
|
+
if selector_hex == "0xe3ead59e":
|
|
1186
|
+
executor = "0x" + data_bytes[:32][-20:].hex()
|
|
1187
|
+
src, dest, from_amt, to_amt, _quoted, _meta, beneficiary = abi_decode(
|
|
1188
|
+
["address", "address", "uint256", "uint256", "uint256", "bytes32", "address"],
|
|
1189
|
+
data_bytes[32:256])
|
|
1190
|
+
partner_and_fee = int.from_bytes(data_bytes[256:288], "big")
|
|
1191
|
+
partner = (partner_and_fee >> 96) & ((1 << 160) - 1)
|
|
1192
|
+
fee_bps = partner_and_fee & 0x3FFF
|
|
1193
|
+
if executor.lower() not in PARASWAP_EXECUTORS:
|
|
1194
|
+
print(f" ⚠ ParaSwap executor {executor} not in the KNOWN whitelist - the on-chain facet")
|
|
1195
|
+
print(f" may reject it with InvalidExecutor(). Proceeding anyway; verify on-chain.")
|
|
1196
|
+
if partner != 0 or fee_bps != 0:
|
|
1197
|
+
raise RuntimeError(f"ParaSwap calldata carries a non-zero partner/fee "
|
|
1198
|
+
f"(partner={hex(partner)}, feeBps={fee_bps}); the facet would revert. Refusing.")
|
|
1199
|
+
if Web3.to_checksum_address(src) != Web3.to_checksum_address(src_token) or \
|
|
1200
|
+
Web3.to_checksum_address(dest) != Web3.to_checksum_address(dest_token):
|
|
1201
|
+
raise RuntimeError("ParaSwap calldata src/dest token mismatch vs request. Refusing.")
|
|
1202
|
+
zero = "0x" + "00" * 20
|
|
1203
|
+
if Web3.to_checksum_address(beneficiary) not in (Web3.to_checksum_address(zero), pa_cs):
|
|
1204
|
+
raise RuntimeError(f"ParaSwap beneficiary {Web3.to_checksum_address(beneficiary)} "
|
|
1205
|
+
f"is neither zero nor the Degen Account. Refusing.")
|
|
1206
|
+
if from_amt != expected_from:
|
|
1207
|
+
raise RuntimeError(f"ParaSwap fromAmount {from_amt} != expected {expected_from}. Refusing.")
|
|
1208
|
+
return executor, src, dest, from_amt, to_amt
|
|
1209
|
+
# UniV3 variant: selector + length sanity only.
|
|
1210
|
+
return None, src_token, dest_token, expected_from, None
|
|
1211
|
+
|
|
1212
|
+
# Executor fallback - mirrors DeltaPrime's swap-debt path. If the ParaSwap API returns a
|
|
1213
|
+
# new executor not on the whitelist, patch in this one (the canonical legacy executor
|
|
1214
|
+
# whose calldata format is compatible with the current API output).
|
|
1215
|
+
_PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
|
|
1216
|
+
|
|
1217
|
+
def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.0,
|
|
1218
|
+
execute: bool = False):
|
|
1219
|
+
"""Swap one in-account asset for another via the Degen Account on ParaSwap v6.
|
|
1220
|
+
Sells the account's in-account balance of --from for --to. Carries remainsSolvent,
|
|
1221
|
+
so the --execute path appends a RedStone signed-price payload to the calldata."""
|
|
1222
|
+
from_sym, to_sym = from_sym.upper(), to_sym.upper()
|
|
1223
|
+
if from_sym == to_sym:
|
|
1224
|
+
print("--from and --to must differ.")
|
|
1225
|
+
return
|
|
1226
|
+
|
|
1227
|
+
w3 = get_w3()
|
|
1228
|
+
acct = get_account()
|
|
1229
|
+
print(f"Wallet: {acct.address}")
|
|
1230
|
+
pa = get_prime_account(w3, acct.address)
|
|
1231
|
+
if not pa:
|
|
1232
|
+
print("No Degen Account exists for this wallet - nothing to swap.")
|
|
1233
|
+
print("Create and fund one first: degenprime create-account --execute")
|
|
1234
|
+
return
|
|
1235
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1236
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1237
|
+
|
|
1238
|
+
from_cfg = _swap_asset_meta(w3, from_sym)
|
|
1239
|
+
to_cfg = _swap_asset_meta(w3, to_sym)
|
|
1240
|
+
if from_cfg is None:
|
|
1241
|
+
print(f"Unknown --from asset '{from_sym}'. Pool symbols: {', '.join(SWAP_ASSETS)}; "
|
|
1242
|
+
"or any TokenManager-listed collateral symbol.")
|
|
1243
|
+
return
|
|
1244
|
+
if to_cfg is None:
|
|
1245
|
+
print(f"Unknown --to asset '{to_sym}'. Pool symbols: {', '.join(SWAP_ASSETS)}; "
|
|
1246
|
+
"or any TokenManager-listed collateral symbol.")
|
|
1247
|
+
return
|
|
1248
|
+
|
|
1249
|
+
amount_in = int(amount * 10**from_cfg["decimals"])
|
|
1250
|
+
in_balance = account.functions.getBalance(asset_b32(from_sym)).call()
|
|
1251
|
+
if amount_in > in_balance:
|
|
1252
|
+
print(f"Degen Account holds only {in_balance / 10**from_cfg['decimals']:.6f} {from_sym} "
|
|
1253
|
+
f"in-account; cannot swap {amount} {from_sym}.")
|
|
1254
|
+
print("Fund or borrow more of the asset into the account first.")
|
|
1255
|
+
return
|
|
1256
|
+
|
|
1257
|
+
price_route = _paraswap_price_route(from_cfg["token"], from_cfg["decimals"],
|
|
1258
|
+
to_cfg["token"], to_cfg["decimals"], amount_in, pa_cs)
|
|
1259
|
+
quoted_out = int(price_route["destAmount"])
|
|
1260
|
+
tx_built = _paraswap_build_tx(price_route, from_cfg["token"], from_cfg["decimals"],
|
|
1261
|
+
to_cfg["token"], to_cfg["decimals"], amount_in,
|
|
1262
|
+
slippage_pct, pa_cs)
|
|
1263
|
+
full = bytes.fromhex(tx_built["data"][2:])
|
|
1264
|
+
selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
|
|
1265
|
+
_exec, _src, _dest, _from_amt, min_out = _paraswap_decode_and_check(
|
|
1266
|
+
selector_hex, data_bytes, from_cfg["token"], to_cfg["token"], amount_in, pa_cs)
|
|
1267
|
+
if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
|
|
1268
|
+
fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
|
|
1269
|
+
data_bytes = fallback_bytes + data_bytes[32:]
|
|
1270
|
+
print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
|
|
1271
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"], to_cfg["token"],
|
|
1272
|
+
amount_in, pa_cs)
|
|
1273
|
+
|
|
1274
|
+
print(f"Swap {amount} {from_sym} -> {to_sym} on Degen Account {pa_cs} (via ParaSwap/Velora)")
|
|
1275
|
+
print(f" Router method: {price_route['contractMethod']} ({selector_hex})")
|
|
1276
|
+
print(f" Augustus router: {tx_built['to']}")
|
|
1277
|
+
print(f" Expected out: {quoted_out / 10**to_cfg['decimals']:.6f} {to_sym}")
|
|
1278
|
+
if min_out is not None:
|
|
1279
|
+
print(f" Min out (@{slippage_pct}% slippage): {min_out / 10**to_cfg['decimals']:.6f} {to_sym}")
|
|
1280
|
+
print(f" ParaSwap srcUSD ${price_route.get('srcUSD','?')} -> destUSD ${price_route.get('destUSD','?')}")
|
|
1281
|
+
print(" Facet enforces a 5% hard slippage cap (RedStone-priced) on top of this.")
|
|
1282
|
+
|
|
1283
|
+
if not execute:
|
|
1284
|
+
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
1285
|
+
return
|
|
1286
|
+
|
|
1287
|
+
feeds = degen_account_price_feeds(account)
|
|
1288
|
+
for s in (from_sym, to_sym):
|
|
1289
|
+
if s in REDSTONE_AVAILABLE_FEEDS and s not in feeds:
|
|
1290
|
+
feeds.append(s)
|
|
1291
|
+
payload = build_redstone_payload(feeds)
|
|
1292
|
+
base_calldata = account.encode_abi("paraSwapV6", args=[full[:4], data_bytes])
|
|
1293
|
+
data = base_calldata + payload.hex()
|
|
1294
|
+
tx = {
|
|
1295
|
+
"from": acct.address, "to": pa_cs, "data": data,
|
|
1296
|
+
"nonce": w3.eth.get_transaction_count(acct.address),
|
|
1297
|
+
"gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1298
|
+
}
|
|
1299
|
+
signed = acct.sign_transaction(tx)
|
|
1300
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1301
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1302
|
+
ok = receipt["status"] == 1
|
|
1303
|
+
print(f"{'✓' if ok else '✗'} Swap {amount} {from_sym} -> {to_sym} "
|
|
1304
|
+
f"{'confirmed' if ok else 'failed'}")
|
|
1305
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1306
|
+
return ok
|
|
1307
|
+
|
|
1308
|
+
# ─── Swap debt / refinance (SwapDebtFacet) ───────────────────────────────────
|
|
1309
|
+
# swapDebtParaSwap borrows _borrowAmount of _toAsset, ParaSwaps it into _fromAsset, and
|
|
1310
|
+
# repays _repayAmount of the _fromAsset debt - all in one tx. The facet hard-caps the
|
|
1311
|
+
# USD value difference between the repay and borrow legs at 5% (RedStone-priced) and
|
|
1312
|
+
# requires the ParaSwap quote's fromAmount to equal _borrowAmount exactly.
|
|
1313
|
+
|
|
1314
|
+
_SYMBOL_TO_POOL = {cfg["symbol"]: name for name, cfg in POOLS.items()}
|
|
1315
|
+
|
|
1316
|
+
def _read_prices_usd(w3, account, symbols, payload):
|
|
1317
|
+
"""RedStone-gated getPrices read for `symbols` (1e8-scaled USD), payload appended.
|
|
1318
|
+
Used by swap-debt to value-match the borrow vs repay leg against the facet's own
|
|
1319
|
+
5% cap (same numbers the contract sees). Symbols must all have RedStone feeds."""
|
|
1320
|
+
data = account.encode_abi("getPrices", args=[[asset_b32(s) for s in symbols]]) + payload.hex()
|
|
1321
|
+
raw = w3.eth.call({"to": account.address, "data": data})
|
|
1322
|
+
return w3.codec.decode(["uint256[]"], bytes(raw))[0]
|
|
1323
|
+
|
|
1324
|
+
def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.0,
|
|
1325
|
+
execute: bool = False):
|
|
1326
|
+
"""Refinance debt from --from (existing debt) into --to (new debt) via
|
|
1327
|
+
swapDebtParaSwap. --amount is how much of the OLD (--from) debt to repay, in --from
|
|
1328
|
+
units. We value-match the new borrow to the repay using the facet's own RedStone
|
|
1329
|
+
prices, build the ParaSwap calldata for the internal --to -> --from swap, and
|
|
1330
|
+
preview the 5% USD-diff cap. RedStone-gated on execute. Both assets must be
|
|
1331
|
+
DegenPrime POOL assets (the swap-debt path touches pool getBorrowed)."""
|
|
1332
|
+
from_sym, to_sym = from_sym.upper(), to_sym.upper()
|
|
1333
|
+
if from_sym not in SWAP_ASSETS:
|
|
1334
|
+
print(f"Unknown --from (old debt) asset '{from_sym}'. Must be a pool asset: {', '.join(SWAP_ASSETS)}")
|
|
1335
|
+
return
|
|
1336
|
+
if to_sym not in SWAP_ASSETS:
|
|
1337
|
+
print(f"Unknown --to (new debt) asset '{to_sym}'. Must be a pool asset: {', '.join(SWAP_ASSETS)}")
|
|
1338
|
+
return
|
|
1339
|
+
if from_sym == to_sym:
|
|
1340
|
+
print("--from and --to must differ.")
|
|
1341
|
+
return
|
|
1342
|
+
if from_sym not in REDSTONE_AVAILABLE_FEEDS or to_sym not in REDSTONE_AVAILABLE_FEEDS:
|
|
1343
|
+
print(f"swap-debt requires both assets to have a RedStone primary-prod feed "
|
|
1344
|
+
f"(for the value-match price read). Available: {sorted(REDSTONE_AVAILABLE_FEEDS & {cfg['symbol'] for cfg in POOLS.values()})}")
|
|
1345
|
+
return
|
|
1346
|
+
|
|
1347
|
+
w3 = get_w3()
|
|
1348
|
+
acct = get_account()
|
|
1349
|
+
print(f"Wallet: {acct.address}")
|
|
1350
|
+
pa = get_prime_account(w3, acct.address)
|
|
1351
|
+
if not pa:
|
|
1352
|
+
print("No Degen Account exists for this wallet - no debt to swap.")
|
|
1353
|
+
return
|
|
1354
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1355
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1356
|
+
|
|
1357
|
+
from_cfg, to_cfg = SWAP_ASSETS[from_sym], SWAP_ASSETS[to_sym]
|
|
1358
|
+
|
|
1359
|
+
# Current borrowed of the OLD debt asset, read from its pool.
|
|
1360
|
+
from_pool, _, _ = get_pool_contract(_SYMBOL_TO_POOL[from_sym])
|
|
1361
|
+
borrowed = from_pool.functions.getBorrowed(pa_cs).call()
|
|
1362
|
+
if borrowed == 0:
|
|
1363
|
+
print(f"Degen Account has no {from_sym} debt to refinance.")
|
|
1364
|
+
return
|
|
1365
|
+
repay_amount = min(int(amount * 10**from_cfg["decimals"]), borrowed)
|
|
1366
|
+
|
|
1367
|
+
feeds = degen_account_price_feeds(account)
|
|
1368
|
+
for s in (from_sym, to_sym):
|
|
1369
|
+
if s not in feeds:
|
|
1370
|
+
feeds.append(s)
|
|
1371
|
+
payload = build_redstone_payload(feeds)
|
|
1372
|
+
price_from, price_to = _read_prices_usd(w3, account, [from_sym, to_sym], payload)
|
|
1373
|
+
# borrow_amount such that its USD value ≈ repay USD value:
|
|
1374
|
+
# repay_usd = price_from * repay_amount / 10**from_dec
|
|
1375
|
+
# borrow_amt = repay_usd * 10**to_dec / price_to
|
|
1376
|
+
borrow_amount = (price_from * repay_amount * 10**to_cfg["decimals"]) // (price_to * 10**from_cfg["decimals"])
|
|
1377
|
+
if borrow_amount == 0:
|
|
1378
|
+
print("Computed borrow amount rounds to zero - repay amount too small. Refusing.")
|
|
1379
|
+
return
|
|
1380
|
+
|
|
1381
|
+
repay_usd = price_from * repay_amount / 10**from_cfg["decimals"] / 1e8
|
|
1382
|
+
borrow_usd = price_to * borrow_amount / 10**to_cfg["decimals"] / 1e8
|
|
1383
|
+
diff_bps = (abs(repay_usd - borrow_usd) / max(repay_usd, borrow_usd)) * 10000 if max(repay_usd, borrow_usd) else 0
|
|
1384
|
+
|
|
1385
|
+
price_route = _paraswap_price_route(to_cfg["token"], to_cfg["decimals"],
|
|
1386
|
+
from_cfg["token"], from_cfg["decimals"], borrow_amount, pa_cs)
|
|
1387
|
+
quoted_out = int(price_route["destAmount"])
|
|
1388
|
+
tx_built = _paraswap_build_tx(price_route, to_cfg["token"], to_cfg["decimals"],
|
|
1389
|
+
from_cfg["token"], from_cfg["decimals"], borrow_amount,
|
|
1390
|
+
slippage_pct, pa_cs)
|
|
1391
|
+
full = bytes.fromhex(tx_built["data"][2:])
|
|
1392
|
+
selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
|
|
1393
|
+
_exec, _src, _dest, _swap_from_amt, swap_min_out = _paraswap_decode_and_check(
|
|
1394
|
+
selector_hex, data_bytes, to_cfg["token"], from_cfg["token"], borrow_amount, pa_cs)
|
|
1395
|
+
if _exec.lower() not in PARASWAP_EXECUTORS:
|
|
1396
|
+
fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
|
|
1397
|
+
data_bytes = fallback_bytes + data_bytes[32:]
|
|
1398
|
+
print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
|
|
1399
|
+
_paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"], from_cfg["token"],
|
|
1400
|
+
borrow_amount, pa_cs)
|
|
1401
|
+
|
|
1402
|
+
print(f"Swap debt on Degen Account {pa}")
|
|
1403
|
+
print(f" Refinance: {from_sym} debt -> {to_sym} debt")
|
|
1404
|
+
print(f" Old debt ({from_sym}): {borrowed / 10**from_cfg['decimals']:.6f} total; "
|
|
1405
|
+
f"repaying {repay_amount / 10**from_cfg['decimals']:.6f}")
|
|
1406
|
+
print(f" New debt ({to_sym}): borrow {borrow_amount / 10**to_cfg['decimals']:.6f}")
|
|
1407
|
+
print(f" Internal swap (ParaSwap): {borrow_amount / 10**to_cfg['decimals']:.6f} {to_sym} "
|
|
1408
|
+
f"-> {from_sym} ({price_route['contractMethod']} {selector_hex})")
|
|
1409
|
+
print(f" Expected {from_sym} out: {quoted_out / 10**from_cfg['decimals']:.6f}", end="")
|
|
1410
|
+
if swap_min_out is not None:
|
|
1411
|
+
print(f" (min {swap_min_out / 10**from_cfg['decimals']:.6f} @{slippage_pct}% slippage)")
|
|
1412
|
+
else:
|
|
1413
|
+
print()
|
|
1414
|
+
print(f" RedStone USD: repay ${repay_usd:,.4f} vs borrow ${borrow_usd:,.4f} "
|
|
1415
|
+
f"-> diff {diff_bps:.1f} bps (facet cap: 500 bps / 5%)")
|
|
1416
|
+
if diff_bps > 500:
|
|
1417
|
+
print(" ✗ USD-value diff exceeds the facet's 5% cap. swapDebtParaSwap would revert. Refusing.")
|
|
1418
|
+
return
|
|
1419
|
+
if quoted_out < repay_amount:
|
|
1420
|
+
print(f" Note: quoted {from_sym} out is below the repay target; the facet repays "
|
|
1421
|
+
f"min(swap output, {repay_amount / 10**from_cfg['decimals']:.6f}, debt) - any shortfall "
|
|
1422
|
+
"leaves residual old debt.")
|
|
1423
|
+
|
|
1424
|
+
if not execute:
|
|
1425
|
+
print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
|
|
1426
|
+
return
|
|
1427
|
+
|
|
1428
|
+
base_calldata = account.encode_abi("swapDebtParaSwap", args=[
|
|
1429
|
+
asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
|
|
1430
|
+
full[:4], data_bytes,
|
|
1431
|
+
])
|
|
1432
|
+
data = base_calldata + payload.hex()
|
|
1433
|
+
tx = {
|
|
1434
|
+
"from": acct.address, "to": pa_cs, "data": data,
|
|
1435
|
+
"nonce": w3.eth.get_transaction_count(acct.address),
|
|
1436
|
+
"gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1437
|
+
}
|
|
1438
|
+
signed = acct.sign_transaction(tx)
|
|
1439
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1440
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1441
|
+
ok = receipt["status"] == 1
|
|
1442
|
+
print(f"{'✓' if ok else '✗'} Swap debt {from_sym} -> {to_sym} {'confirmed' if ok else 'failed'}")
|
|
1443
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1444
|
+
|
|
1445
|
+
# ─── Collateral withdrawal (WithdrawalIntentFacet) ──────────────────────────
|
|
1446
|
+
# Universal 24h time-lock on DegenPrime - NOT just risky assets. createWithdrawalIntent
|
|
1447
|
+
# registers an intent (no RedStone), then executeWithdrawalIntent pulls it after
|
|
1448
|
+
# maturity (RedStone-gated). Window (from the IntentInfo flags on-chain):
|
|
1449
|
+
# actionableAt = createdAt + 24h, expiresAt = actionableAt + 48h. So an intent is
|
|
1450
|
+
# executable in a 24h-72h window. cancelWithdrawalIntent drops a pending intent.
|
|
1451
|
+
|
|
1452
|
+
def _fmt_window(actionable_at: int, expires_at: int) -> str:
|
|
1453
|
+
"""Human one-liner for an intent's maturity window, anchored to chain time."""
|
|
1454
|
+
now = int(time.time())
|
|
1455
|
+
def rel(ts):
|
|
1456
|
+
d = ts - now
|
|
1457
|
+
sign = "in" if d >= 0 else "ago"
|
|
1458
|
+
d = abs(d)
|
|
1459
|
+
h, m = d // 3600, (d % 3600) // 60
|
|
1460
|
+
span = f"{h}h{m:02d}m" if h else f"{m}m"
|
|
1461
|
+
return f"{sign} {span}" if sign == "in" else f"{span} {sign}"
|
|
1462
|
+
a = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(actionable_at))
|
|
1463
|
+
e = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(expires_at))
|
|
1464
|
+
return f"actionable {a} ({rel(actionable_at)}), expires {e} ({rel(expires_at)})"
|
|
1465
|
+
|
|
1466
|
+
def cmd_withdraw_collateral(pool_name: str, amount: float, execute: bool = False):
|
|
1467
|
+
"""Step 1 of collateral withdrawal: register a WithdrawalIntent via
|
|
1468
|
+
createWithdrawalIntent(bytes32 asset, uint256 amount). Does NOT need a RedStone
|
|
1469
|
+
payload (the solvency check is deferred to the execute step). After ~24h the intent
|
|
1470
|
+
becomes executable for a 48h window (see execute-withdrawal). Preview by default."""
|
|
1471
|
+
cfg = POOLS[pool_name]
|
|
1472
|
+
w3 = get_w3()
|
|
1473
|
+
acct = get_account()
|
|
1474
|
+
print(f"Wallet: {acct.address}")
|
|
1475
|
+
pa = get_prime_account(w3, acct.address)
|
|
1476
|
+
if not pa:
|
|
1477
|
+
print("No Degen Account exists for this wallet - nothing to withdraw.")
|
|
1478
|
+
return
|
|
1479
|
+
|
|
1480
|
+
symbol = pool_to_asset_symbol(pool_name)
|
|
1481
|
+
amount_wei = int(amount * 10**cfg["decimals"])
|
|
1482
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1483
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1484
|
+
|
|
1485
|
+
# getAvailableBalance is oracle-free: in-account minus pending intents.
|
|
1486
|
+
available = account.functions.getAvailableBalance(asset_b32(symbol)).call()
|
|
1487
|
+
print(f"Create withdrawal intent: {amount} {symbol} from Degen Account {pa}")
|
|
1488
|
+
print(f" Available to withdraw now: {available / 10**cfg['decimals']:.6f} {symbol}")
|
|
1489
|
+
if amount_wei > available:
|
|
1490
|
+
print(f" ✗ Requested {amount} {symbol} exceeds available balance. Refusing.")
|
|
1491
|
+
return
|
|
1492
|
+
print(f" Calls createWithdrawalIntent(bytes32 '{symbol}', {amount_wei}) - no RedStone payload needed.")
|
|
1493
|
+
print(" Universal time-lock: becomes executable ~24h later, then has a 48h window (24h-72h total).")
|
|
1494
|
+
print(" Run `execute-withdrawal --pool <p>` after maturity to pull the funds to the wallet.")
|
|
1495
|
+
|
|
1496
|
+
if not execute:
|
|
1497
|
+
print("Run with --execute to broadcast (registers the intent on-chain).")
|
|
1498
|
+
return
|
|
1499
|
+
|
|
1500
|
+
tx = account.functions.createWithdrawalIntent(asset_b32(symbol), amount_wei).build_transaction({
|
|
1501
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
1502
|
+
"gas": 1000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1503
|
+
})
|
|
1504
|
+
signed = acct.sign_transaction(tx)
|
|
1505
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1506
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1507
|
+
ok = receipt["status"] == 1
|
|
1508
|
+
print(f"{'✓' if ok else '✗'} Withdrawal intent {'registered' if ok else 'failed'}")
|
|
1509
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1510
|
+
|
|
1511
|
+
def cmd_withdrawal_intents():
|
|
1512
|
+
"""Read-only: list pending withdrawal intents per owned asset, with per-asset
|
|
1513
|
+
available balance. Uses oracle-free WithdrawalIntentFacet views - no RedStone."""
|
|
1514
|
+
w3 = get_w3()
|
|
1515
|
+
acct = get_account()
|
|
1516
|
+
pa = get_prime_account(w3, acct.address)
|
|
1517
|
+
print(f"Wallet: {acct.address}")
|
|
1518
|
+
if not pa:
|
|
1519
|
+
print("No Degen Account yet - no withdrawal intents.")
|
|
1520
|
+
return
|
|
1521
|
+
|
|
1522
|
+
print(f"Degen Account: {pa}")
|
|
1523
|
+
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
1524
|
+
owned = account.functions.getAllOwnedAssets().call()
|
|
1525
|
+
if not owned:
|
|
1526
|
+
print(" Account holds no assets - nothing to withdraw.")
|
|
1527
|
+
return
|
|
1528
|
+
|
|
1529
|
+
any_pending = False
|
|
1530
|
+
for a in owned:
|
|
1531
|
+
sym = a.rstrip(b"\x00").decode(errors="replace")
|
|
1532
|
+
dec = _asset_decimals(w3, sym)
|
|
1533
|
+
available = account.functions.getAvailableBalance(a).call()
|
|
1534
|
+
total_intent = account.functions.getTotalIntentAmount(a).call()
|
|
1535
|
+
intents = account.functions.getUserIntents(a).call()
|
|
1536
|
+
print(f" {sym}: available {available / 10**dec:,.6f}, "
|
|
1537
|
+
f"pending intents {total_intent / 10**dec:,.6f}")
|
|
1538
|
+
for idx, (amt, actionable_at, expires_at, is_pending, is_actionable, is_expired) in enumerate(intents):
|
|
1539
|
+
any_pending = True
|
|
1540
|
+
if is_expired:
|
|
1541
|
+
state = "EXPIRED"
|
|
1542
|
+
elif is_actionable:
|
|
1543
|
+
state = "READY to execute"
|
|
1544
|
+
elif is_pending:
|
|
1545
|
+
state = "maturing"
|
|
1546
|
+
else:
|
|
1547
|
+
state = "inactive"
|
|
1548
|
+
print(f" [{idx}] {amt / 10**dec:,.6f} {sym} - {state}")
|
|
1549
|
+
print(f" {_fmt_window(actionable_at, expires_at)}")
|
|
1550
|
+
if not any_pending:
|
|
1551
|
+
print(" No pending withdrawal intents.")
|
|
1552
|
+
|
|
1553
|
+
def cmd_execute_withdrawal(pool_name: str, index: int = None, execute: bool = False):
|
|
1554
|
+
"""Step 2 of collateral withdrawal: executeWithdrawalIntent(bytes32 asset,
|
|
1555
|
+
uint256[] indices) pulls matured intent(s) to the EOA. RedStone-gated, so
|
|
1556
|
+
--execute appends a fresh RedStone price payload. Refuses any intent not yet
|
|
1557
|
+
matured (isActionable=false) or expired. --index selects one intent; default
|
|
1558
|
+
executes all currently-actionable intents (indices passed strictly increasing,
|
|
1559
|
+
as the contract requires)."""
|
|
1560
|
+
cfg = POOLS[pool_name]
|
|
1561
|
+
w3 = get_w3()
|
|
1562
|
+
acct = get_account()
|
|
1563
|
+
print(f"Wallet: {acct.address}")
|
|
1564
|
+
pa = get_prime_account(w3, acct.address)
|
|
1565
|
+
if not pa:
|
|
1566
|
+
print("No Degen Account exists for this wallet - nothing to execute.")
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
symbol = pool_to_asset_symbol(pool_name)
|
|
1570
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1571
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1572
|
+
intents = account.functions.getUserIntents(asset_b32(symbol)).call()
|
|
1573
|
+
if not intents:
|
|
1574
|
+
print(f"No withdrawal intents registered for {symbol}.")
|
|
1575
|
+
print("Register one first: withdraw-collateral --pool <p> --amount <n> --execute")
|
|
1576
|
+
return
|
|
1577
|
+
|
|
1578
|
+
if index is not None:
|
|
1579
|
+
if index < 0 or index >= len(intents):
|
|
1580
|
+
print(f"--index {index} out of range (asset has {len(intents)} intent(s)).")
|
|
1581
|
+
return
|
|
1582
|
+
candidates = [index]
|
|
1583
|
+
else:
|
|
1584
|
+
candidates = [i for i, it in enumerate(intents) if it[4]] # isActionable
|
|
1585
|
+
|
|
1586
|
+
print(f"Execute withdrawal of {symbol} from Degen Account {pa}")
|
|
1587
|
+
ready = []
|
|
1588
|
+
for i in candidates:
|
|
1589
|
+
amt, actionable_at, expires_at, _is_pending, is_actionable, is_expired = intents[i]
|
|
1590
|
+
print(f" [{i}] {amt / 10**cfg['decimals']:,.6f} {symbol} - "
|
|
1591
|
+
f"{'EXPIRED' if is_expired else 'READY' if is_actionable else 'NOT MATURED'}")
|
|
1592
|
+
print(f" {_fmt_window(actionable_at, expires_at)}")
|
|
1593
|
+
if is_expired:
|
|
1594
|
+
print(f" ✗ intent [{i}] has expired - cannot execute (cancel/clear it instead).")
|
|
1595
|
+
elif not is_actionable:
|
|
1596
|
+
print(f" ✗ intent [{i}] has not matured yet - refusing.")
|
|
1597
|
+
else:
|
|
1598
|
+
ready.append(i)
|
|
1599
|
+
|
|
1600
|
+
if not ready:
|
|
1601
|
+
print(" No matured, non-expired intents to execute. Refusing.")
|
|
1602
|
+
return
|
|
1603
|
+
ready.sort()
|
|
1604
|
+
print(f" Will execute indices {ready} via executeWithdrawalIntent(bytes32 '{symbol}', {ready}).")
|
|
1605
|
+
print(" Carries remainsSolvent - appends a fresh RedStone payload.")
|
|
1606
|
+
|
|
1607
|
+
if not execute:
|
|
1608
|
+
print("Run with --execute to broadcast (pulls the funds to the wallet).")
|
|
1609
|
+
return
|
|
1610
|
+
|
|
1611
|
+
feeds = degen_account_price_feeds(account)
|
|
1612
|
+
if symbol in REDSTONE_AVAILABLE_FEEDS and symbol not in feeds:
|
|
1613
|
+
feeds.append(symbol)
|
|
1614
|
+
payload = build_redstone_payload(feeds)
|
|
1615
|
+
base_calldata = account.encode_abi("executeWithdrawalIntent", args=[asset_b32(symbol), ready])
|
|
1616
|
+
data = base_calldata + payload.hex()
|
|
1617
|
+
tx = {
|
|
1618
|
+
"from": acct.address, "to": pa_cs, "data": data,
|
|
1619
|
+
"nonce": w3.eth.get_transaction_count(acct.address),
|
|
1620
|
+
"gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1621
|
+
}
|
|
1622
|
+
signed = acct.sign_transaction(tx)
|
|
1623
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1624
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1625
|
+
ok = receipt["status"] == 1
|
|
1626
|
+
print(f"{'✓' if ok else '✗'} Execute withdrawal {'confirmed' if ok else 'failed'}")
|
|
1627
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1628
|
+
|
|
1629
|
+
def cmd_cancel_withdrawal(pool_name: str, index: int, execute: bool = False):
|
|
1630
|
+
"""Cancel a pending withdrawal intent before it matures (or before it's executed).
|
|
1631
|
+
Calls cancelWithdrawalIntent(bytes32 asset, uint256 intentIndex) - oracle-free,
|
|
1632
|
+
no RedStone payload. Useful when changing your mind about a queued withdrawal,
|
|
1633
|
+
or when freeing up the locked amount for swap-debt / repay first."""
|
|
1634
|
+
cfg = POOLS[pool_name]
|
|
1635
|
+
w3 = get_w3()
|
|
1636
|
+
acct = get_account()
|
|
1637
|
+
print(f"Wallet: {acct.address}")
|
|
1638
|
+
pa = get_prime_account(w3, acct.address)
|
|
1639
|
+
if not pa:
|
|
1640
|
+
print("No Degen Account exists for this wallet - nothing to cancel.")
|
|
1641
|
+
return
|
|
1642
|
+
|
|
1643
|
+
symbol = pool_to_asset_symbol(pool_name)
|
|
1644
|
+
pa_cs = Web3.to_checksum_address(pa)
|
|
1645
|
+
account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
|
|
1646
|
+
intents = account.functions.getUserIntents(asset_b32(symbol)).call()
|
|
1647
|
+
if not intents:
|
|
1648
|
+
print(f"No withdrawal intents registered for {symbol}.")
|
|
1649
|
+
return
|
|
1650
|
+
if index < 0 or index >= len(intents):
|
|
1651
|
+
print(f"--index {index} out of range (asset has {len(intents)} intent(s)).")
|
|
1652
|
+
return
|
|
1653
|
+
|
|
1654
|
+
amt, actionable_at, expires_at, _is_pending, is_actionable, is_expired = intents[index]
|
|
1655
|
+
state = "EXPIRED" if is_expired else "READY" if is_actionable else "maturing"
|
|
1656
|
+
print(f"Cancel withdrawal intent [{index}] for {symbol} on Degen Account {pa}")
|
|
1657
|
+
print(f" Amount: {amt / 10**cfg['decimals']:,.6f} {symbol} ({state})")
|
|
1658
|
+
print(f" {_fmt_window(actionable_at, expires_at)}")
|
|
1659
|
+
print(f" Calls cancelWithdrawalIntent(bytes32 '{symbol}', {index}) - no RedStone payload needed.")
|
|
1660
|
+
|
|
1661
|
+
if not execute:
|
|
1662
|
+
print("Run with --execute to broadcast.")
|
|
1663
|
+
return
|
|
1664
|
+
|
|
1665
|
+
tx = account.functions.cancelWithdrawalIntent(asset_b32(symbol), index).build_transaction({
|
|
1666
|
+
"from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
|
|
1667
|
+
"gas": 1000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
|
|
1668
|
+
})
|
|
1669
|
+
signed = acct.sign_transaction(tx)
|
|
1670
|
+
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
1671
|
+
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
|
|
1672
|
+
ok = receipt["status"] == 1
|
|
1673
|
+
print(f"{'✓' if ok else '✗'} Cancel withdrawal intent [{index}] {'confirmed' if ok else 'failed'}")
|
|
1674
|
+
print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
|
|
1675
|
+
|
|
1676
|
+
# ─── Aerodrome (read-only for v1) ────────────────────────────────────────────
|
|
1677
|
+
|
|
1678
|
+
def cmd_aerodrome_positions():
|
|
1679
|
+
"""Read-only: list the Aerodrome NFT tokenIds the Degen Account owns/has staked,
|
|
1680
|
+
via the diamond's getOwnedStakedAerodromeTokenIds view. Write paths (add/remove/
|
|
1681
|
+
stake liquidity) are deferred to v2 - the on-chain signatures vary by Aerodrome
|
|
1682
|
+
version and need per-market probing before broadcasting. Position composition
|
|
1683
|
+
(per-token amounts) needs the getPositionCompositionSimplified return shape, which
|
|
1684
|
+
we don't decode in v1; just listing IDs keeps this safe and useful."""
|
|
1685
|
+
w3 = get_w3()
|
|
1686
|
+
acct = get_account()
|
|
1687
|
+
print(f"Wallet: {acct.address}")
|
|
1688
|
+
pa = get_prime_account(w3, acct.address)
|
|
1689
|
+
if not pa:
|
|
1690
|
+
print("No Degen Account yet - no Aerodrome positions.")
|
|
1691
|
+
return
|
|
1692
|
+
account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
|
|
1693
|
+
print(f"Degen Account: {pa}")
|
|
1694
|
+
try:
|
|
1695
|
+
ids = account.functions.getOwnedStakedAerodromeTokenIds().call()
|
|
1696
|
+
except Exception as e:
|
|
1697
|
+
print(f" Aerodrome read failed: {type(e).__name__}: {e}")
|
|
1698
|
+
return
|
|
1699
|
+
if not ids:
|
|
1700
|
+
print(" No Aerodrome positions (owned/staked tokenIds).")
|
|
1701
|
+
return
|
|
1702
|
+
print(f" {len(ids)} Aerodrome NFT tokenId(s):")
|
|
1703
|
+
for tid in ids:
|
|
1704
|
+
print(f" [{tid}] https://aerodrome.finance/positions (manage on Aerodrome UI)")
|
|
1705
|
+
print(" v1 lists tokenIds only. Composition + write paths deferred to v2.")
|
|
1706
|
+
|
|
1707
|
+
def main():
|
|
1708
|
+
try:
|
|
1709
|
+
_dispatch()
|
|
1710
|
+
except RuntimeError as e:
|
|
1711
|
+
print(f"degenprime: {e}", file=sys.stderr)
|
|
1712
|
+
sys.exit(1)
|
|
1713
|
+
|
|
1714
|
+
def _dispatch():
|
|
1715
|
+
args = sys.argv[1:] if len(sys.argv) > 1 else []
|
|
1716
|
+
# Global signing-key override: --key <0xhex>, stripped before command dispatch.
|
|
1717
|
+
global _CLI_KEY
|
|
1718
|
+
if "--key" in args:
|
|
1719
|
+
i = args.index("--key")
|
|
1720
|
+
if i + 1 >= len(args):
|
|
1721
|
+
print("--key requires a hex key. Example: --key 0xabc...")
|
|
1722
|
+
return
|
|
1723
|
+
_CLI_KEY = args[i + 1]
|
|
1724
|
+
del args[i:i + 2]
|
|
1725
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1726
|
+
print(__doc__)
|
|
1727
|
+
return
|
|
1728
|
+
|
|
1729
|
+
cmd = args[0]
|
|
1730
|
+
if cmd == "pool-info":
|
|
1731
|
+
pool = args[1] if len(args) > 1 else "all"
|
|
1732
|
+
if pool != "all" and pool not in POOLS:
|
|
1733
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}, all")
|
|
1734
|
+
return
|
|
1735
|
+
cmd_pool_info(pool)
|
|
1736
|
+
elif cmd == "my-positions":
|
|
1737
|
+
cmd_my_positions()
|
|
1738
|
+
elif cmd == "deposit":
|
|
1739
|
+
pool, amount = None, None
|
|
1740
|
+
execute = "--execute" in args
|
|
1741
|
+
for i, a in enumerate(args):
|
|
1742
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1743
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1744
|
+
if not pool or amount is None:
|
|
1745
|
+
print("Usage: degenprime deposit --pool usdc --amount 100 [--execute]")
|
|
1746
|
+
return
|
|
1747
|
+
if pool not in POOLS:
|
|
1748
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1749
|
+
return
|
|
1750
|
+
cmd_deposit(pool, amount, execute)
|
|
1751
|
+
elif cmd == "withdraw":
|
|
1752
|
+
pool, amount = None, None
|
|
1753
|
+
execute = "--execute" in args
|
|
1754
|
+
for i, a in enumerate(args):
|
|
1755
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1756
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1757
|
+
if not pool or amount is None:
|
|
1758
|
+
print("Usage: degenprime withdraw --pool usdc --amount 100 [--execute]")
|
|
1759
|
+
return
|
|
1760
|
+
if pool not in POOLS:
|
|
1761
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1762
|
+
return
|
|
1763
|
+
cmd_withdraw(pool, amount, execute)
|
|
1764
|
+
elif cmd in ("create-account", "create-degen-account"):
|
|
1765
|
+
fund_pool, fund_amount = None, None
|
|
1766
|
+
for i, a in enumerate(args):
|
|
1767
|
+
if a == "--fund-pool" and i + 1 < len(args): fund_pool = args[i + 1]
|
|
1768
|
+
if a == "--fund-amount" and i + 1 < len(args): fund_amount = float(args[i + 1])
|
|
1769
|
+
if (fund_pool is None) != (fund_amount is None):
|
|
1770
|
+
print("Pass both --fund-pool and --fund-amount, or neither.")
|
|
1771
|
+
return
|
|
1772
|
+
if fund_pool is not None and fund_pool not in POOLS:
|
|
1773
|
+
print(f"Unknown pool '{fund_pool}'. Choose from: {', '.join(POOLS)}")
|
|
1774
|
+
return
|
|
1775
|
+
cmd_create_account("--execute" in args, fund_pool, fund_amount)
|
|
1776
|
+
elif cmd == "summary":
|
|
1777
|
+
cmd_summary()
|
|
1778
|
+
elif cmd == "fund":
|
|
1779
|
+
pool, amount = None, None
|
|
1780
|
+
execute = "--execute" in args
|
|
1781
|
+
for i, a in enumerate(args):
|
|
1782
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1783
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1784
|
+
if not pool or amount is None:
|
|
1785
|
+
print("Usage: degenprime fund --pool usdc --amount 100 [--execute]")
|
|
1786
|
+
return
|
|
1787
|
+
if pool not in POOLS:
|
|
1788
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1789
|
+
return
|
|
1790
|
+
cmd_fund(pool, amount, execute)
|
|
1791
|
+
elif cmd in ("borrow", "repay"):
|
|
1792
|
+
pool, amount = None, None
|
|
1793
|
+
execute = "--execute" in args
|
|
1794
|
+
for i, a in enumerate(args):
|
|
1795
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1796
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1797
|
+
if not pool or amount is None:
|
|
1798
|
+
print(f"Usage: degenprime {cmd} --pool usdc --amount 100 [--execute]")
|
|
1799
|
+
return
|
|
1800
|
+
if pool not in POOLS:
|
|
1801
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1802
|
+
return
|
|
1803
|
+
(cmd_borrow if cmd == "borrow" else cmd_repay)(pool, amount, execute)
|
|
1804
|
+
elif cmd == "swap":
|
|
1805
|
+
from_sym, to_sym, amount, slippage = None, None, None, 1.0
|
|
1806
|
+
execute = "--execute" in args
|
|
1807
|
+
for i, a in enumerate(args):
|
|
1808
|
+
if a == "--from" and i + 1 < len(args): from_sym = args[i + 1]
|
|
1809
|
+
if a == "--to" and i + 1 < len(args): to_sym = args[i + 1]
|
|
1810
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1811
|
+
if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
|
|
1812
|
+
if not from_sym or not to_sym or amount is None:
|
|
1813
|
+
print("Usage: degenprime swap --from USDC --to ETH --amount 10 [--slippage 0.5] [--execute]")
|
|
1814
|
+
return
|
|
1815
|
+
cmd_swap(from_sym, to_sym, amount, slippage, execute)
|
|
1816
|
+
elif cmd == "swap-debt":
|
|
1817
|
+
from_sym, to_sym, amount, slippage = None, None, None, 1.0
|
|
1818
|
+
execute = "--execute" in args
|
|
1819
|
+
for i, a in enumerate(args):
|
|
1820
|
+
if a == "--from" and i + 1 < len(args): from_sym = args[i + 1]
|
|
1821
|
+
if a == "--to" and i + 1 < len(args): to_sym = args[i + 1]
|
|
1822
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1823
|
+
if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
|
|
1824
|
+
if not from_sym or not to_sym or amount is None:
|
|
1825
|
+
print("Usage: degenprime swap-debt --from ETH --to USDC --amount 100 [--slippage 0.5] [--execute]")
|
|
1826
|
+
return
|
|
1827
|
+
cmd_swap_debt(from_sym, to_sym, amount, slippage, execute)
|
|
1828
|
+
elif cmd == "withdraw-collateral":
|
|
1829
|
+
pool, amount = None, None
|
|
1830
|
+
execute = "--execute" in args
|
|
1831
|
+
for i, a in enumerate(args):
|
|
1832
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1833
|
+
if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
|
|
1834
|
+
if not pool or amount is None:
|
|
1835
|
+
print("Usage: degenprime withdraw-collateral --pool usdc --amount 100 [--execute]")
|
|
1836
|
+
return
|
|
1837
|
+
if pool not in POOLS:
|
|
1838
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1839
|
+
return
|
|
1840
|
+
cmd_withdraw_collateral(pool, amount, execute)
|
|
1841
|
+
elif cmd == "withdrawal-intents":
|
|
1842
|
+
cmd_withdrawal_intents()
|
|
1843
|
+
elif cmd == "execute-withdrawal":
|
|
1844
|
+
pool, index = None, None
|
|
1845
|
+
execute = "--execute" in args
|
|
1846
|
+
for i, a in enumerate(args):
|
|
1847
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1848
|
+
if a == "--index" and i + 1 < len(args): index = int(args[i + 1])
|
|
1849
|
+
if not pool:
|
|
1850
|
+
print("Usage: degenprime execute-withdrawal --pool usdc [--index N] [--execute]")
|
|
1851
|
+
return
|
|
1852
|
+
if pool not in POOLS:
|
|
1853
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1854
|
+
return
|
|
1855
|
+
cmd_execute_withdrawal(pool, index, execute)
|
|
1856
|
+
elif cmd == "cancel-withdrawal":
|
|
1857
|
+
pool, index = None, None
|
|
1858
|
+
execute = "--execute" in args
|
|
1859
|
+
for i, a in enumerate(args):
|
|
1860
|
+
if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
|
|
1861
|
+
if a == "--index" and i + 1 < len(args): index = int(args[i + 1])
|
|
1862
|
+
if not pool or index is None:
|
|
1863
|
+
print("Usage: degenprime cancel-withdrawal --pool usdc --index N [--execute]")
|
|
1864
|
+
return
|
|
1865
|
+
if pool not in POOLS:
|
|
1866
|
+
print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
|
|
1867
|
+
return
|
|
1868
|
+
cmd_cancel_withdrawal(pool, index, execute)
|
|
1869
|
+
elif cmd == "aerodrome-positions":
|
|
1870
|
+
cmd_aerodrome_positions()
|
|
1871
|
+
else:
|
|
1872
|
+
print(f"Unknown command: {cmd}\n{__doc__}")
|
|
1873
|
+
|
|
1874
|
+
if __name__ == "__main__":
|
|
1875
|
+
main()
|