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/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()