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/deltaprime.py ADDED
@@ -0,0 +1,3974 @@
1
+ #!/usr/bin/env python3
2
+ """DeltaPrime Protocol interaction module (Avalanche C-chain).
3
+
4
+ Lending pools take direct EOA deposits/withdrawals. Borrowing and leverage go
5
+ through a Prime Account: a per-user SmartLoan (EIP-2535 diamond) created via the
6
+ SmartLoansFactory. The EOA owns it; borrow/repay/fund run on the Prime Account,
7
+ which itself talks to the pools.
8
+
9
+ Usage:
10
+ deltaprime pool-info [usdc|wavax|weth|btc|usdt|all]
11
+ deltaprime my-positions
12
+ deltaprime deposit --pool usdc --amount 100 [--execute]
13
+ deltaprime withdraw --pool usdc --amount 100 [--execute]
14
+ deltaprime create-prime-account [--execute] (alias: create-account)
15
+ deltaprime create-prime-account --fund-pool usdc --fund-amount 100 [--execute]
16
+ deltaprime prime-summary
17
+ deltaprime defi --json (aggregate ALL positions as DeBank-style JSON; read-only)
18
+ deltaprime fund --pool usdc --amount 100 [--execute]
19
+ deltaprime borrow --pool usdc --amount 100 [--execute]
20
+ deltaprime repay --pool usdc --amount 100 [--execute]
21
+ deltaprime swap --from USDC --to AVAX --amount 10 [--via yak|paraswap] [--slippage 0.5] [--execute]
22
+ deltaprime swap-debt --from AVAX --to USDC --amount 100 [--slippage 0.5] [--execute]
23
+ deltaprime withdraw-collateral --pool usdc --amount 100 [--execute]
24
+ deltaprime withdrawal-intents
25
+ deltaprime execute-withdrawal --pool usdc [--index N] [--execute]
26
+ deltaprime gmx-positions
27
+ deltaprime gmx-deposit --market avax-usdc --amount 10 [--side long|short] [--slippage 1] [--fee-buffer 2] [--execute]
28
+ deltaprime gmx-withdraw --market avax+ --amount 5 [--slippage 1] [--fee-buffer 2] [--execute]
29
+ deltaprime lb-positions
30
+ deltaprime lb-add --pair avax-usdc --amount-x 1 --amount-y 30 [--shape spot|curve|bidask] [--range 5] [--slippage 1] [--id-slippage 5] [--execute]
31
+ deltaprime lb-remove --pair avax-usdc [--slippage 1] [--execute]
32
+ deltaprime sjoe-position
33
+ deltaprime sjoe-stake --amount 100 [--execute]
34
+ deltaprime sjoe-unstake --amount 100 [--execute]
35
+ deltaprime sjoe-claim [--execute]
36
+ deltaprime prime-tier
37
+ deltaprime prime-needed --borrow 1000 [--tier premium|basic]
38
+ deltaprime prime-deposit --amount 200 [--execute]
39
+ deltaprime prime-activate [--amount N] [--execute]
40
+ deltaprime prime-deactivate [--withdraw] [--execute]
41
+ deltaprime prime-unstake --amount N [--execute]
42
+ deltaprime prime-repay --amount N [--execute]
43
+ deltaprime zap --market avax-usdc --collateral wavax --collateral-amount 1 --borrow-amount 30 --deposit-amount 30 [--side long|short] [--swap] [--slippage 1] [--fee-buffer 2] [--execute]
44
+
45
+ Configuration (env vars):
46
+ DELTAPRIME_PRIVATE_KEY Raw 0x... private key for the signer.
47
+ DELTAPRIME_KEY_FILE Path to a file containing the 0x key (alternative to the env var).
48
+ DELTAPRIME_RPC Avalanche C-chain RPC URL (defaults to api.avax.network).
49
+ --key <0xhex> One-off CLI override (takes precedence over both env vars).
50
+
51
+ prime-summary reports live solvency (health ratio, total value, debt, solvent flag) from
52
+ SolvencyFacetProdAvalanche, read via eth_call with a RedStone price payload appended (falls
53
+ back to balances-only if the gateway is unreachable).
54
+
55
+ Collateral withdrawal is a two-step, time-delayed flow on the Prime Account (there is NO
56
+ instant withdraw of in-account collateral; the savings-pool `withdraw` above is separate).
57
+ withdraw-collateral registers a WithdrawalIntent (createWithdrawalIntent, no RedStone). The
58
+ intent becomes executable ~24h later for a 48h window (24h-72h total); execute-withdrawal
59
+ then pulls it to the wallet (executeWithdrawalIntent, RedStone-gated). withdrawal-intents
60
+ lists pending intents + per-asset available balance (oracle-free reads). The maturity window
61
+ and ready/expired state come straight off-chain from the IntentInfo struct.
62
+
63
+ Leverage flow: create-prime-account -> fund (collateral) -> borrow -> repay -> withdraw.
64
+ fund moves collateral from the wallet into the Prime Account; borrow needs a funded
65
+ account. ERC20 assets approve the account then call fund(); native AVAX (wavax pool)
66
+ uses the payable depositNativeToken(). create-prime-account --fund-* does both in one
67
+ tx via createAndFundLoan() (ERC20 only).
68
+
69
+ swap trades one in-account asset for another on the Prime Account, via either aggregator
70
+ route (--via, default yak):
71
+ - yak (YieldYakSwapFacet.yakSwap): the YieldYak router's findBestPath derives the
72
+ path+adapters off-chain; the swap runs against the account's in-account balance of
73
+ the --from asset. Every adapter must be whitelisted on the facet.
74
+ - paraswap (ParaSwapFacet.paraSwapV6): the ParaSwap/Velora v6.2 API for Avalanche
75
+ builds the swap calldata (/prices price route -> /transactions tx data). The facet
76
+ takes paraSwapV6(bytes4 selector, bytes data) — we split the API calldata into its
77
+ 4-byte selector + remaining bytes and pass them through. Only the two router methods
78
+ the facet decodes are accepted: swapExactAmountIn (0xe3ead59e) and
79
+ swapExactAmountInOnUniswapV3 (0x876a02f6). The facet enforces a hard 5% slippage cap
80
+ (RedStone-priced) regardless of --slippage.
81
+ Both routes carry remainsSolvent, so --execute appends a RedStone signed-price payload to
82
+ the calldata (see the RedStone wrapping helpers below). Asset names are the bytes32
83
+ symbols (AVAX/ETH/BTC/USDC/USDT), not the wrapped-token names.
84
+
85
+ swap-debt refinances debt from one asset into another in a single tx via
86
+ SwapDebtFacet.swapDebtParaSwap(_fromAsset, _toAsset, _repayAmount, _borrowAmount, selector,
87
+ data): it borrows --amount of the NEW debt asset (--to), ParaSwaps it into the OLD debt
88
+ asset (--from), and repays the old debt. --from is the existing debt being refinanced;
89
+ --to is the new debt taken on. The facet enforces a hard 5% cap on the USD-value
90
+ difference between the repaid and borrowed amounts (RedStone-priced), and requires the
91
+ ParaSwap quote's fromAmount to equal the borrow amount exactly. RedStone-gated on execute.
92
+
93
+ gmx-deposit / gmx-withdraw open/close GMX V2 GM (two-sided) and GM+ (single-sided) LP
94
+ positions on the Prime Account, via GmxV2FacetAvalanche (deposit{Avax,Btc,Eth}UsdcGmxV2 /
95
+ withdraw{...}UsdcGmxV2) and GmxV2PlusFacetAvalanche (deposit{Avax,Btc,Eth}GmxV2Plus /
96
+ withdraw{...}GmxV2Plus). Markets (--market): avax-usdc, btc-usdc, eth-usdc (GM); avax+,
97
+ btc+, eth+ (GM+). gmx-deposit takes an in-account underlying (two-sided: --side long|short,
98
+ long = volatile leg, short = USDC; GM+ ignores --side); gmx-withdraw burns GM tokens.
99
+ - These functions are PAYABLE + ASYNC. They pay a GMX execution fee as msg.value (== the
100
+ executionFee arg; the facet reverts InvalidExecutionFee if they differ), queue the
101
+ request on the GMX ExchangeRouter, and a GMX KEEPER executes it some blocks later via a
102
+ callback. The position does NOT appear/disappear instantly, and the Prime Account is
103
+ FROZEN for that market until the keeper callback fires. The fee is estimated from the GMX
104
+ DataStore gas params (callbackGasLimit 600000) times the gas price, padded by --fee-buffer
105
+ (default 2x) to survive a gas-price rise before keeper execution; GMX refunds any excess
106
+ to the account. The EOA also needs AVAX for its own tx gas on top of the execution fee.
107
+ - minGmAmount (deposit) / min long+short token amounts (withdraw) are slippage floors set
108
+ from the RedStone oracle prices minus --slippage. The facet's isWithinBounds check
109
+ HARD-CAPS slippage at 5% (±5% of the oracle estimate) — looser reverts InvalidMinOutput.
110
+ - RedStone-gated: --execute appends a signed price payload (GM feed + underlyings). The GM
111
+ token price has no SolvencyFacet feed, so it is read from the RedStone gateway median (the
112
+ same on-demand value the facet aggregates from calldata).
113
+ gmx-positions is read-only: per owned market it shows the GM balance after the accrued
114
+ performance fee (SmartLoanViewFacet.getGmTokenBalanceAfterFees) and the annualised
115
+ performance (getGm[Plus]Performance) — both RedStone-gated views, eth_call'd with a payload.
116
+
117
+ lb-add / lb-remove open/close TraderJoe V2 Liquidity Book (concentrated liquidity) positions
118
+ on the Prime Account via TraderJoeV2AvalancheFacet (addLiquidityTraderJoeV2 /
119
+ removeLiquidityTraderJoeV2). --pair is a whitelisted LB pair key (avax-usdc, avax-usdc-20,
120
+ btc-usdc, eth-avax, btc-avax, avax-btc, eurc-usdc, usdt-usdc, joe-avax). LB liquidity sits in
121
+ discrete price BINS; a position is encoded as deltaIds[] (bin offsets from the active bin) +
122
+ distributionX[]/distributionY[] (per-bin token weightings, each populated side summing to
123
+ 1e18). --shape sets that weighting: spot = uniform across the range (default, the common
124
+ case), curve = concentrated near the active price, bidask = concentrated at the range edges.
125
+ --range R spreads liquidity over R bins each side of the active bin (2R+1 total). Token X (the
126
+ pair's base) fills bins at/above the active bin; token Y (the quote) fills bins at/below it.
127
+ - lb-add takes per-token amounts (--amount-x / --amount-y, in token units; one-sided is fine)
128
+ from in-account balances. amountXMin/amountYMin are slippage floors; --id-slippage guards
129
+ the active-bin id shifting before inclusion. The facet overrides to/refundTo to the account.
130
+ - Max 80 bins per Prime Account (cumulative across pairs); both the preview and the on-chain
131
+ facet enforce it (TooManyBins). The preview projects the post-add bin count and refuses if
132
+ it would exceed 80.
133
+ - addLiquidity carries remainsSolvent, so --execute appends a RedStone signed-price payload.
134
+ removeLiquidity is NOT solvency-gated, so lb-remove needs no payload. lb-remove closes the
135
+ account's ENTIRE position for the pair (all owned bins).
136
+ lb-positions is read-only: getOwnedTraderJoeV2Bins (oracle-free) lists owned (pair, bin) pairs;
137
+ per pair it shows the active bin and the account's share of each bin's reserves (balanceOf /
138
+ totalSupply * getBin) as per-token totals. No RedStone, no tx.
139
+
140
+ sjoe-stake / sjoe-unstake / sjoe-claim drive TraderJoe's sJOE staking on the Prime Account via
141
+ SJoeFacet (0x8aD9028f60Cf0F823271FE689EbDD0A58492cC75): stake in-account JOE to earn USDC fee
142
+ rewards, unstake JOE back into the account, claim accrued USDC. Verified on Snowtrace 23-05-2026
143
+ against the verified SJoeFacet source:
144
+ - stakeJoe(uint256): onlyOwner + remainsSolvent + noBorrowInTheSameBlock + notInLiquidation, so
145
+ --execute appends a RedStone signed-price payload. Caps to the account's in-account JOE.
146
+ - unstakeJoe(uint256): onlyOwnerOrInsolvent + noBorrowInTheSameBlock — NOT remainsSolvent, so it
147
+ needs no payload (same as lb-remove). Caps to the staked JOE.
148
+ - claimSJoeRewards(): onlyOwner + remainsSolvent + noBorrowInTheSameBlock, so --execute appends a
149
+ payload. Drives the sJOE withdraw(0) reward-claim path.
150
+ Every reward-bearing call (stake/unstake/claim) skims a 10% protocol fee off the USDC claimed in that
151
+ tx (CLAIMING_FEE = 0.1e18, split stability-pool/treasury), so the account nets ~90% of the rewards
152
+ realised. sjoe-position is read-only: joeBalanceInSJoe (staked JOE, 18-dec) + rewardsInSJoe (pending
153
+ USDC, 6-dec), both oracle-free views — no RedStone, no tx.
154
+
155
+ zap is a tool-level MACRO (zaps are NOT a separate on-chain facet — they are front-end orchestration
156
+ that chains the existing primitives, capabilities §7). One bounded "leveraged long" flow, composing
157
+ the existing leg commands (no new ABI), terminating in a GMX V2 GM market deposit:
158
+ 1. fund --collateral collateral into the Prime Account,
159
+ 2. borrow --borrow-amount USDC against it (the leverage),
160
+ 3. OPTIONAL (--swap): YieldYak-swap the borrowed USDC into the market's long token,
161
+ 4. gmx-deposit --deposit-amount of the chosen leg (--side long|short) into --market.
162
+ Each leg is its OWN transaction with an EXPLICIT amount (no fragile auto-sizing across the oracle/async
163
+ boundary). PREVIEW prints the full ordered plan and runs each leg in preview (nothing broadcast),
164
+ flagging which legs are RedStone-gated. --EXECUTE runs the legs sequentially and STOPS immediately on
165
+ the first failure, reporting exactly which legs completed and which failed (partial-state safety — a
166
+ halted zap leaves the completed legs live on-chain, so it warns to review with prime-summary before
167
+ retrying rather than blindly re-running). The terminal GMX leg is ASYNC: --execute only FIRES the
168
+ deposit request — a GMX keeper mints the GM tokens later and the account is FROZEN until then
169
+ (re-check gmx-positions once the keeper settles). Only the GM-terminal leveraged long is built; an LB-terminal
170
+ long is reachable by running fund -> borrow -> [swap] then lb-add manually.
171
+
172
+ prime-* drive DeltaPrime's PRIME-token leverage tiers (PrimeLeverageFacet on the Prime Account). Two tiers:
173
+ BASIC (~5x, the default) and PREMIUM (10x). PREMIUM is gated by STAKING the protocol's PRIME token in an
174
+ amount PROPORTIONAL to USD borrow (tieredPrimeStakingRatio, ~1.2 PRIME/$100), and it accrues a PRIME-
175
+ denominated rent-debt over time (tieredPrimeDebtRatio, ~0.5 PRIME/$100/yr). Both ratios live in the
176
+ TokenManager and are governance-mutable, so the tool reads them on-chain (getRequiredPrimeStake), never
177
+ hard-codes them. PRIME (18-dec) is a separate token from sPRIME and must be acquired on a DEX (LFJ/TraderJoe
178
+ PRIME-WAVAX). Verified 24-05-2026 against the verified PrimeLeverageFacet source.
179
+ - prime-tier: read-only status — current tier, staked PRIME, recorded PRIME debt (last snapshot), the EOA
180
+ + in-account PRIME balances, and shouldLiquidatePrimeDebt() (state-mutating, so eth_call'd as a read-only sim).
181
+ - prime-needed --borrow X [--tier premium|basic]: read-only quote of PRIME needed to back $X of borrow, via
182
+ getRequiredPrimeStake (live ratio). Default tier premium.
183
+ - prime-activate [--amount N]: --amount first depositPrime(N)s PRIME from the EOA into the account (ERC20
184
+ approve -> depositPrime, RedStone-gated), then stakePrimeAndActivatePremium() stakes the required amount
185
+ (against 10x your free collateral) and flips to PREMIUM. Omit --amount to stake from PRIME already in the
186
+ account. Preview shows the plan + projected required stake and fails closed if the in-account PRIME is short.
187
+ - prime-deactivate [--withdraw]: deactivatePremiumTier — repays ALL PRIME debt first (reverts if PRIME can't
188
+ cover it; 50% burn / 50% treasury), drops to BASIC. --withdraw also releases the freed stake into the account.
189
+ - prime-unstake --amount N: unstakePrime — release staked PRIME; in PREMIUM the remaining stake must still cover
190
+ the USD ratio + accrued PRIME debt or the facet reverts.
191
+ - prime-repay --amount N: repayPrimeDebt — repay accrued PRIME rent-debt from in-account PRIME (capped to the
192
+ current debt, 50% burn / 50% treasury).
193
+ Only depositPrime (inside prime-activate --amount) is solvency-gated -> RedStone payload on --execute; every other
194
+ prime-* write is onlyOwner and needs no payload. All prime-* views are oracle-free. Preview by default; --execute broadcasts.
195
+ """
196
+
197
+ import json, os, sys, time, re, base64
198
+ from decimal import Decimal, ROUND_HALF_UP
199
+ from pathlib import Path
200
+ import requests
201
+ from eth_account import Account
202
+ from eth_keys import keys as eth_keys
203
+ from eth_abi import encode as abi_encode, decode as abi_decode
204
+ from web3 import Web3
205
+ from web3.middleware import ExtraDataToPOAMiddleware
206
+
207
+ # Default Avalanche C-chain RPC. Override with the DELTAPRIME_RPC env var (paid
208
+ # Alchemy/QuickNode/Infura endpoints are recommended for higher throughput; the
209
+ # public endpoint rate-limits hard on busy `defi --json` reads).
210
+ AVALANCHE_RPC = os.environ.get("DELTAPRIME_RPC", "https://api.avax.network/ext/bc/C/rpc")
211
+ EXPLORER = "https://snowtrace.io"
212
+ CHAIN_ID = 43114
213
+ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
214
+ # ── Signing key resolution ──────────────────────────────────────────────────
215
+ # The Prime Account is derived on-chain from the wallet owner (getLoanForOwner),
216
+ # so each user automatically operates on their own Prime Account — no per-user
217
+ # addresses are hardcoded.
218
+ #
219
+ # Key resolution order (first hit wins; see resolve_private_key):
220
+ # 1. --key <0xhex> CLI flag -> raw 0x... key (one-off escape hatch)
221
+ # 2. DELTAPRIME_PRIVATE_KEY env var -> raw 0x... key (primary path)
222
+ # 3. DELTAPRIME_KEY_FILE env var -> path to a file containing the 0x key
223
+ #
224
+ # The CLI key (#1) is set by main() before the command runs; the env vars (#2/#3)
225
+ # are read lazily so read-only commands that don't sign never need a key at all.
226
+ _CLI_KEY = None # set by the --key CLI flag in main()
227
+ SNOWTRACE = "https://api.snowtrace.io/api"
228
+ FACTORY_PROXY = "0x3Ea9D480295A73fd2aF95b4D96c2afF88b21B03D"
229
+ # On-chain registry of active pools. getPoolAddress(bytes32 asset) is the source
230
+ # of truth — the docs/repo per-pool artifacts are stale and point at frozen pools.
231
+ TOKEN_MANAGER = "0xF3978209B7cfF2b90100C6F87CEC77dE928Ed58e"
232
+ # SmartLoan diamond beacon. Every Prime Account is a per-user proxy that delegates
233
+ # here, so the facet ABIs (borrow/repay/fund + view fns) are reachable at any
234
+ # deployed account address. Sourced from SmartLoansFactory.smartLoanDiamond().
235
+ SMART_LOAN_DIAMOND = "0x2916B3bf7C35bd21e63D01C93C62FB0d4994e56D"
236
+
237
+ # YieldYak aggregator router. findBestPath() is read-only and returns the optimal
238
+ # multi-hop route (path + per-hop adapter addresses). The Prime Account's
239
+ # YieldYakSwapFacet executes yakSwap() over those, requiring every adapter to be
240
+ # whitelisted (isWhitelistedAdapterOptimized).
241
+ YAK_ROUTER = "0xC4729E56b831d74bBc18797e0e17A295fA77488c"
242
+
243
+ # ParaSwap / Velora v6.2 aggregator. The Prime Account's ParaSwapFacet.paraSwapV6 and
244
+ # SwapDebtFacet.swapDebtParaSwap both call this Augustus router with API-built calldata
245
+ # (verified hard-coded as PARA_ROUTER in the deployed ParaSwapHelper). The facet only
246
+ # decodes two router methods, so the API route must resolve to one of these selectors:
247
+ # swapExactAmountIn 0xe3ead59e (generic executor route)
248
+ # swapExactAmountInOnUniV3 0x876a02f6 (Uniswap-V3 direct route)
249
+ # It validates the decoded executor against a fixed allowlist, the partner against the
250
+ # treasury (we force partner=0), the beneficiary against the account (0 is allowed), and
251
+ # applies a 5% hard slippage cap priced via RedStone.
252
+ PARASWAP_API = "https://apiv5.paraswap.io"
253
+ PARASWAP_AUGUSTUS = "0x6A000F20005980200259B80c5102003040001068"
254
+ PARASWAP_SUPPORTED_SELECTORS = {"0xe3ead59e", "0x876a02f6"}
255
+ # Executors the facet whitelists (ParaSwapHelper._checkExecutorAddress). Lowercased.
256
+ PARASWAP_EXECUTORS = {
257
+ # Must match the ParaSwap executor whitelist on DeltaPrime's ParaSwapFacet and
258
+ # SwapDebtFacet. The ParaSwap API can return new executors that aren't whitelisted
259
+ # yet — those cause on-chain InvalidExecutor() reverts. Only add executors verified
260
+ # to be whitelisted on-chain.
261
+ "0xdef171fe48cf0115b1d80b88dc8eab59176fee57",
262
+ "0x6a000f20005980200259b80c5102003040001068",
263
+ "0x000010036c0190e009a000d0fc3541100a07380a",
264
+ "0x00c600b30fb0400701010f4b080409018b9006e0",
265
+ "0xa0f408a000017007015e0f00320e470d00090a5b",
266
+ }
267
+
268
+ # RedStone on-demand oracle config for DeltaPrime on Avalanche. The Prime Account's
269
+ # solvency math (every remainsSolvent-gated facet call, plus oracle views like
270
+ # getHealthRatio/isSolvent/getTotalValue) reads signed prices appended to the tx
271
+ # calldata. Values are from AvalancheDataServiceConsumerBase in the deployed source:
272
+ # data service "redstone-avalanche-prod", 3-of-5 unique authorised signers, default
273
+ # 3-minute staleness window. The 9-byte marker terminates a RedStone payload.
274
+ # DeltaPrime uses RedStone PRIMARY production (PrimaryProdDataServiceConsumerBase), not Classic.
275
+ # The authorised signer set and gateway endpoint MUST match.
276
+ REDSTONE_DATA_SERVICE = "redstone-primary-prod"
277
+ REDSTONE_SIGNERS_THRESHOLD = 3
278
+ REDSTONE_MARKER = bytes.fromhex("000002ed57011e0000")
279
+ REDSTONE_GATEWAYS = [
280
+ "https://oracle-gateway-1.a.redstone.finance",
281
+ "https://oracle-gateway-2.a.redstone.finance",
282
+ ]
283
+
284
+ # Active pool proxies resolved from TokenManager.getPoolAddress() and verified
285
+ # on-chain (2026-05-23) by matching totalSupply() to the live app sizes. The old
286
+ # docs addresses are frozen: totalSupply() still returns stale values, but the
287
+ # deposit/withdraw write paths revert with PoolFrozen() (0xfd4851e9).
288
+ POOLS = {
289
+ "usdc": {
290
+ "proxy": "0x8027e004d80274FB320e9b8f882C92196d779CE8",
291
+ "token": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
292
+ "symbol": "USDC", "decimals": 6, "native": False,
293
+ },
294
+ "wavax": {
295
+ "proxy": "0xaa39f39802F8C44e48d4cc42E088C09EDF4daad4",
296
+ "token": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7",
297
+ "symbol": "AVAX", "decimals": 18, "native": True,
298
+ },
299
+ "weth": {
300
+ "proxy": "0x2A84c101F3d45610595050a622684d5412bdf510",
301
+ "token": "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB",
302
+ "symbol": "ETH", "decimals": 18, "native": False,
303
+ },
304
+ "btc": {
305
+ "proxy": "0x70e80001bDbeC5b9e932cEe2FEcC8F123c98F738",
306
+ "token": "0x152b9d0FdC40C096757F570A51E494bd4b943E50",
307
+ "symbol": "BTC", "decimals": 8, "native": False,
308
+ },
309
+ "usdt": {
310
+ "proxy": "0x1b6D7A6044fB68163D8E249Bce86F3eFbb12368e",
311
+ "token": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7",
312
+ "symbol": "USDT", "decimals": 6, "native": False,
313
+ },
314
+ }
315
+
316
+ # ─── GMX V2 GM / GM+ markets (LP) ────────────────────────────────────────────
317
+ # DeltaPrime mints/redeems GMX V2 market LP tokens (GM = two-sided long+short, GM+ =
318
+ # single-sided) through two diamond facets, reachable at any Prime Account address.
319
+ # Deposit/withdraw are PAYABLE + ASYNC: a GMX execution fee is paid as msg.value, the
320
+ # request is queued on the GMX ExchangeRouter, and a GMX keeper executes it some blocks
321
+ # later via the callbacks facet. The Prime Account is FROZEN for that market until the
322
+ # keeper callback fires. Facet/function signatures + the executionFee==msg.value rule are
323
+ # verified against the deployed verified source on Snowtrace (23-05-2026).
324
+ # GmxV2FacetAvalanche 0x759902b8D105cBB20D1b2C7b76b355a175E32286 (two-sided)
325
+ # GmxV2PlusFacetAvalanche 0xe9C87e730f3a5972C9EA78995d32eb2Fd936D7Bf (single-sided)
326
+ # Each market: the GM token, its long/short underlying lending symbols, the RedStone GM
327
+ # feed id (priced off the gateway median — SolvencyFacet.getPrices has no feed for the GM
328
+ # symbol and reverts 0xec459bc0, so GM prices come straight from the gateway, the same
329
+ # source the contract reads from calldata), and the facet deposit/withdraw fn stems.
330
+ GMX_MARKETS = {
331
+ # Two-sided GM markets (volatile leg + USDC). depositXUsdcGmxV2(bool isLongToken, ...).
332
+ "avax-usdc": {
333
+ "plus": False, "gm_token": "0x913C1F46b48b3eD35E7dc3Cf754d4ae8499F31CF",
334
+ "long": "AVAX", "short": "USDC", "gm_feed": "GM_AVAX_WAVAX_USDC",
335
+ "deposit_fn": "depositAvaxUsdcGmxV2", "withdraw_fn": "withdrawAvaxUsdcGmxV2",
336
+ },
337
+ "btc-usdc": {
338
+ "plus": False, "gm_token": "0xFb02132333A79C8B5Bd0b64E3AbccA5f7fAf2937",
339
+ "long": "BTC", "short": "USDC", "gm_feed": "GM_BTC_BTCb_USDC",
340
+ "deposit_fn": "depositBtcUsdcGmxV2", "withdraw_fn": "withdrawBtcUsdcGmxV2",
341
+ },
342
+ "eth-usdc": {
343
+ "plus": False, "gm_token": "0xB7e69749E3d2EDd90ea59A4932EFEa2D41E245d7",
344
+ "long": "ETH", "short": "USDC", "gm_feed": "GM_ETH_WETHe_USDC",
345
+ "deposit_fn": "depositEthUsdcGmxV2", "withdraw_fn": "withdrawEthUsdcGmxV2",
346
+ },
347
+ # Single-sided GM+ markets (one asset, no USDC short leg). depositXGmxV2Plus(...).
348
+ # long == short underlying; the facet splits a deposit 50/50 across both legs.
349
+ "avax+": {
350
+ "plus": True, "gm_token": "0x08b25A2a89036d298D6dB8A74ace9d1ce6Db15E5",
351
+ "long": "AVAX", "short": "AVAX", "gm_feed": "GM_AVAX_WAVAX",
352
+ "deposit_fn": "depositAvaxGmxV2Plus", "withdraw_fn": "withdrawAvaxGmxV2Plus",
353
+ },
354
+ "btc+": {
355
+ "plus": True, "gm_token": "0x3ce7BCDB37Bf587d1C17B930Fa0A7000A0648D12",
356
+ "long": "BTC", "short": "BTC", "gm_feed": "GM_BTC_BTCb",
357
+ "deposit_fn": "depositBtcGmxV2Plus", "withdraw_fn": "withdrawBtcGmxV2Plus",
358
+ },
359
+ "eth+": {
360
+ "plus": True, "gm_token": "0x2A3Cf4ad7db715DF994393e4482D6f1e58a1b533",
361
+ "long": "ETH", "short": "ETH", "gm_feed": "GM_ETH_WETHe",
362
+ "deposit_fn": "depositEthGmxV2Plus", "withdraw_fn": "withdrawEthGmxV2Plus",
363
+ },
364
+ }
365
+
366
+ # GMX V2 infra used for execution-fee estimation. The DataStore holds the gas-limit
367
+ # params; the keeper requires executionFee >= adjustedGasLimit * tx.gasprice at execution
368
+ # time, so the fee is estimated as (base + perOracle*count + estimate*multiplier/1e30) *
369
+ # gasPrice, then padded (see _estimate_gmx_execution_fee). callbackGasLimit is hard-coded
370
+ # to 600000 in both facets. Addresses are the verified facet constants (Snowtrace).
371
+ GMX_DATASTORE = "0x2F0b22339414ADeD7D5F06f9D604c7fF5b2fe3f6"
372
+ GMX_READER = "0x62Cb8740E6986B29dC671B2EB596676f60590A5B"
373
+ GMX_CALLBACK_GAS_LIMIT = 600000
374
+ # GMX market token decimals are 18; the underlyings reuse the lending-pool decimals.
375
+ GM_TOKEN_DECIMALS = 18
376
+ # isWithinBounds (DiamondMethodsAccess) requires the USD value of the user's min-output to
377
+ # be within ±5% of the contract's own oracle estimate. So slippage on minGmAmount /
378
+ # min-token-outs is hard-capped at 5% — anything looser reverts InvalidMinOutputValue.
379
+ GMX_MAX_SLIPPAGE_PCT = 5.0
380
+
381
+ # ─── TraderJoe V2 Liquidity Book (concentrated liquidity) ────────────────────
382
+ # DeltaPrime LPs into TraderJoe V2 LB pairs through TraderJoeV2AvalancheFacet, reachable
383
+ # at any Prime Account. Liquidity is spread across discrete price BINS; each bin is a
384
+ # fixed price and binStep sets the spacing (in basis points). A position is encoded as
385
+ # deltaIds[] (bin offsets from the active bin), distributionX[]/distributionY[] (per-bin
386
+ # weightings of each token, each side summing to 1e18), an activeIdDesired+idSlippage
387
+ # guard, and amountX/Y + mins. "Shape" (Spot/Curve/Bid-Ask) is just the distribution
388
+ # arrays over the chosen range. Facet/struct/router details verified on Snowtrace
389
+ # 23-05-2026 against the verified TraderJoeV2AvalancheFacet + ILBRouter/ILBPair source:
390
+ # - addLiquidityTraderJoeV2(router, LiquidityParameters): remainsSolvent (RedStone-gated),
391
+ # validates router + pair whitelist, overrides to/refundTo to the account, enforces
392
+ # maxBinsPerPrimeAccount()==80 AFTER the add (cumulative across the account's bins).
393
+ # - removeLiquidityTraderJoeV2(router, RemoveLiquidityParameters): NOT remainsSolvent
394
+ # (onlyOwnerOrLiquidation only) so it needs no RedStone payload; binStep is uint16.
395
+ # - The router resolves the pair via getFactory().getLBPairInformation(tokenX,tokenY,binStep);
396
+ # tokenX/tokenY MUST match the pair's canonical getTokenX()/getTokenY() order.
397
+ # - The facet checks _getAvailableBalance(symbol) for each token, where symbol is the
398
+ # TokenManager.tokenAddressToSymbol() value (NB: EURC's account symbol is "EUROC").
399
+ TJ_LB_FACET = "0x1899F6D524637808f2d53125b6CCFe6D2dF1Fa91"
400
+ TJ_ROUTER_V21 = "0xb4315e873dBcf96Ffd0acd8EA43f689D8c20fB30"
401
+ TJ_ROUTER_V22 = "0x18556DA13313f3532c54711497A8FedAC273220E"
402
+ TJ_MAX_BINS = 80
403
+
404
+ # Whitelisted LB pairs exposed as tool keys, matching the DeltaPrime frontend (bin step in
405
+ # the key suffix where a pair exists at two steps). For each: the LBPair address, the
406
+ # router version the pair belongs to, the canonical (tokenX, tokenY) order read on-chain,
407
+ # and the binStep. tokenX/tokenY carry the ERC20 address, the account bytes32 symbol (for
408
+ # the in-account balance read + RedStone feed), and decimals. The 13 source-whitelisted
409
+ # pairs include 4 aUSD pairs not on the frontend; those are omitted (no clean
410
+ # symbol/decimals + out of scope).
411
+ _WAVAX = {"addr": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", "symbol": "AVAX", "decimals": 18}
412
+ _USDC = {"addr": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "symbol": "USDC", "decimals": 6}
413
+ _USDT = {"addr": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", "symbol": "USDT", "decimals": 6}
414
+ _WETH = {"addr": "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB", "symbol": "ETH", "decimals": 18}
415
+ _BTCB = {"addr": "0x152b9d0FdC40C096757F570A51E494bd4b943E50", "symbol": "BTC", "decimals": 8}
416
+ _EURC = {"addr": "0xC891EB4cbdEFf6e073e859e987815Ed1505c2ACD", "symbol": "EUROC", "decimals": 6}
417
+ _JOE = {"addr": "0x6e84a6216eA6dACC71eE8E6b0a5B7322EEbC0fDd", "symbol": "JOE", "decimals": 18}
418
+ TJ_LB_PAIRS = {
419
+ "avax-usdc": {"pair": "0x864d4e5Ee7318e97483DB7EB0912E09F161516EA", "router": TJ_ROUTER_V22, "binStep": 10, "tokenX": _WAVAX, "tokenY": _USDC},
420
+ "avax-usdc-20": {"pair": "0xD446eb1660F766d533BeCeEf890Df7A69d26f7d1", "router": TJ_ROUTER_V21, "binStep": 20, "tokenX": _WAVAX, "tokenY": _USDC},
421
+ "btc-usdc": {"pair": "0x4224f6F4C9280509724Db2DbAc314621e4465C29", "router": TJ_ROUTER_V22, "binStep": 10, "tokenX": _BTCB, "tokenY": _USDC},
422
+ "eth-avax": {"pair": "0x1901011a39B11271578a1283D620373aBeD66faA", "router": TJ_ROUTER_V21, "binStep": 10, "tokenX": _WETH, "tokenY": _WAVAX},
423
+ "btc-avax": {"pair": "0xD9fa522F5BC6cfa40211944F2C8DA785773Ad99D", "router": TJ_ROUTER_V21, "binStep": 10, "tokenX": _BTCB, "tokenY": _WAVAX},
424
+ "avax-btc": {"pair": "0x856b38Bf1e2E367F747DD4d3951DDA8a35F1bF60", "router": TJ_ROUTER_V22, "binStep": 5, "tokenX": _WAVAX, "tokenY": _BTCB},
425
+ "eurc-usdc": {"pair": "0xcD4f57d6B160B4ef2DFb78Ad1c76Cc4242EDB4CE", "router": TJ_ROUTER_V22, "binStep": 2, "tokenX": _EURC, "tokenY": _USDC},
426
+ "usdt-usdc": {"pair": "0x2823299af89285fF1a1abF58DB37cE57006FEf5D", "router": TJ_ROUTER_V21, "binStep": 1, "tokenX": _USDT, "tokenY": _USDC},
427
+ "joe-avax": {"pair": "0xEA7309636E7025Fda0Ee2282733Ea248c3898495", "router": TJ_ROUTER_V21, "binStep": 25, "tokenX": _JOE, "tokenY": _WAVAX},
428
+ }
429
+
430
+ # LB pair (ILBPair) reads used for previews + position views. getActiveId is the current
431
+ # price bin; getTokenX/Y the canonical order; getBin(id) the bin's reserves; balanceOf /
432
+ # totalSupply give the account's share of a bin (LB liquidity is an ERC1155-style token).
433
+ LB_PAIR_ABI = [
434
+ {"inputs": [], "name": "getActiveId", "outputs": [{"type": "uint24"}], "stateMutability": "view", "type": "function"},
435
+ {"inputs": [], "name": "getTokenX", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
436
+ {"inputs": [], "name": "getTokenY", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"},
437
+ {"inputs": [], "name": "getBinStep", "outputs": [{"type": "uint16"}], "stateMutability": "view", "type": "function"},
438
+ {"inputs": [{"name": "id", "type": "uint24"}], "name": "getBin",
439
+ "outputs": [{"name": "binReserveX", "type": "uint128"}, {"name": "binReserveY", "type": "uint128"}],
440
+ "stateMutability": "view", "type": "function"},
441
+ {"inputs": [{"name": "account", "type": "address"}, {"name": "id", "type": "uint256"}], "name": "balanceOf",
442
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
443
+ {"inputs": [{"name": "id", "type": "uint256"}], "name": "totalSupply",
444
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
445
+ ]
446
+
447
+ # ─── sJOE staking (SJoeFacet) ────────────────────────────────────────────────
448
+ # DeltaPrime stakes in-account JOE into TraderJoe's StableJoeStaking (sJOE) to earn USDC fee
449
+ # rewards, via SJoeFacet reachable at any Prime Account. The account holds JOE under bytes32
450
+ # symbol "JOE" (18-dec); rewards accrue in USDC (6-dec). Verified on Snowtrace 23-05-2026 against
451
+ # the verified SJoeFacet source — function names, the SJOE/reward-token constants, the 10%
452
+ # claiming fee, and the per-function modifiers:
453
+ # stakeJoe(uint256) onlyOwner + remainsSolvent + noBorrowInTheSameBlock + notInLiquidation
454
+ # unstakeJoe(uint256) onlyOwnerOrInsolvent + noBorrowInTheSameBlock (NOT remainsSolvent)
455
+ # claimSJoeRewards() onlyOwner + remainsSolvent + noBorrowInTheSameBlock
456
+ # joeBalanceInSJoe()/rewardsInSJoe() oracle-free views (getUserInfo / pendingReward on sJOE)
457
+ # So stake + claim are RedStone-gated (payload appended on --execute); unstake is not. Each
458
+ # reward-bearing call skims CLAIMING_FEE (10%) off the USDC claimed in that tx, split between the
459
+ # stability pool and treasury, so the account nets ~90% of realised rewards.
460
+ SJOE_STAKING = "0x1a731B2299E22FbAC282E7094EdA41046343Cb51"
461
+ SJOE_JOE = {"addr": "0x6e84a6216eA6dACC71eE8E6b0a5B7322EEbC0fDd", "symbol": "JOE", "decimals": 18}
462
+ SJOE_REWARD = {"addr": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "symbol": "USDC", "decimals": 6}
463
+ SJOE_CLAIMING_FEE_PCT = 10.0
464
+
465
+ # ─── PRIME-token leverage tiers (PrimeLeverageFacet) ─────────────────────────
466
+ # DeltaPrime gates higher max-leverage behind staking the protocol's own PRIME token,
467
+ # via PrimeLeverageFacet reachable at any Prime Account. Two tiers (LeverageTierLib.
468
+ # LeverageTier enum, uint8 on the wire): BASIC=0 (~5x default) and PREMIUM=1 (10x).
469
+ # PREMIUM requires PRIME staked PROPORTIONAL to USD borrow (tieredPrimeStakingRatio,
470
+ # 1.2 PRIME / $100 as of 24-05-2026) and accrues a PRIME-denominated rent-debt over time
471
+ # (tieredPrimeDebtRatio, 0.5 PRIME / $100 / yr). BOTH ratios live in the TokenManager and
472
+ # are governance-mutable, so the tool NEVER hard-codes them — it calls getRequiredPrimeStake
473
+ # on-chain. Facet/signatures/flow verified on Snowtrace 24-05-2026 against the verified
474
+ # PrimeLeverageFacet source (0.8.17, BUSL-1.1):
475
+ # depositPrime(uint256) onlyOwner + noBorrowInTheSameBlock + nonReentrant + remainsSolvent
476
+ # -> RedStone-gated; pulls PRIME from the EOA (ERC20 approve first),
477
+ # adds it as an in-account balance (NOT a solvency asset).
478
+ # stakePrimeAndActivatePremium() onlyOwner + nonReentrant (NOT remainsSolvent -> no payload).
479
+ # Stakes getRequiredPrimeStake(PREMIUM, (totalValue-debt)*10) from the
480
+ # IN-ACCOUNT PRIME balance (provisions the 10x-max-debt stake up
481
+ # front), sets tier=PREMIUM. Reverts if already PREMIUM or short PRIME.
482
+ # deactivatePremiumTier(bool) onlyOwner + nonReentrant. Repays ALL PRIME debt first (reverts if it
483
+ # can't), drops to BASIC; bool=true also releases excess stake.
484
+ # unstakePrime(uint256) onlyOwner + nonReentrant. Guards: remaining stake must cover the PREMIUM
485
+ # USD ratio against current debt AND the accrued PRIME debt.
486
+ # repayPrimeDebt(uint256) onlyOwner. Caps to current debt; 50% burn / 50% treasury.
487
+ # getLeverageTier/getLeverageTierFullInfo/getPrimeStakedAmount/getRequiredPrimeStake oracle-free views.
488
+ # shouldLiquidatePrimeDebt() NON-view (mutates: snapshots debt) — we only eth_call it (read-only sim).
489
+ # PRIME token (18-dec) is resolved on-chain via TokenManager.getAssetAddress("PRIME", true). Do NOT confuse
490
+ # with sPRIME (a separate PRIME-AVAX LP receipt token); the facet stakes plain PRIME.
491
+ PRIME_LEVERAGE_FACET = "0x912609401D93779bEd71C9027c5f11f518397Bdd"
492
+ PRIME_TOKEN = {"addr": "0x33c8036e99082b0c395374832fecf70c42c7f298", "symbol": "PRIME", "decimals": 18}
493
+ PRIME_TIERS = {"basic": 0, "premium": 1}
494
+ PRIME_TIER_NAMES = {0: "BASIC", 1: "PREMIUM", 2: "_NON_EXISTENT"}
495
+
496
+ _abi_cache = {}
497
+ _impl_cache = {}
498
+
499
+ def get_w3():
500
+ w3 = Web3(Web3.HTTPProvider(AVALANCHE_RPC))
501
+ w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
502
+ return w3
503
+
504
+ def _tx_gas_price(w3) -> int:
505
+ """Gas price for broadcasts: 2x the current network price with a 1 gwei floor.
506
+ Avalanche's base fee is ~0.02 gwei, so a bare w3.eth.gas_price tx can strand
507
+ (sit unmined / get dropped) if the base fee ticks up after submission. The bump
508
+ guarantees timely inclusion and gives headroom to REPLACE a stranded same-nonce
509
+ tx. Cost is negligible (~3M gas at ~1 gwei = ~0.003 AVAX). NOTE: this is the tx
510
+ gasPrice; the GMX keeper execution-fee floor (25 gwei) is a separate calc, kept as-is."""
511
+ return max(int(w3.eth.gas_price * 2), 10**9)
512
+
513
+ def resolve_private_key():
514
+ """Resolve the signing key per the documented precedence:
515
+ 1. --key <0xhex> CLI flag
516
+ 2. DELTAPRIME_PRIVATE_KEY env var
517
+ 3. DELTAPRIME_KEY_FILE env var (path to a file containing the 0x key)
518
+ Raises with a clear message if none of the three are set."""
519
+ if _CLI_KEY:
520
+ return _CLI_KEY.strip()
521
+ raw = os.environ.get("DELTAPRIME_PRIVATE_KEY")
522
+ if raw:
523
+ return raw.strip()
524
+ key_file = os.environ.get("DELTAPRIME_KEY_FILE")
525
+ if key_file:
526
+ try:
527
+ return Path(key_file).read_text().strip()
528
+ except FileNotFoundError:
529
+ raise RuntimeError(f"DELTAPRIME_KEY_FILE points at {key_file} but the file does not exist.")
530
+ raise RuntimeError(
531
+ "No signing key found. Set DELTAPRIME_PRIVATE_KEY (raw 0x... key), or "
532
+ "DELTAPRIME_KEY_FILE (path to a file containing the key), or pass --key <0xhex>."
533
+ )
534
+
535
+ def get_account() -> Account:
536
+ return Account.from_key(resolve_private_key())
537
+
538
+ def fetch_json(url, params):
539
+ r = requests.get(url, params=params, timeout=15)
540
+ return r.json()
541
+
542
+ def get_pool_impl(pool_proxy: str) -> str:
543
+ p = pool_proxy.lower()
544
+ if p not in _impl_cache:
545
+ d = fetch_json(SNOWTRACE, {"module": "contract", "action": "getsourcecode", "address": pool_proxy})
546
+ impl = d["result"][0].get("Implementation", "")
547
+ _impl_cache[p] = impl or pool_proxy
548
+ return _impl_cache[p]
549
+
550
+ def get_pool_abi(pool_proxy: str) -> list:
551
+ p = pool_proxy.lower()
552
+ if p not in _abi_cache:
553
+ impl = get_pool_impl(pool_proxy)
554
+ d = fetch_json(SNOWTRACE, {"module": "contract", "action": "getabi", "address": impl})
555
+ if d.get("status") == "1":
556
+ _abi_cache[p] = json.loads(d["result"])
557
+ else:
558
+ _abi_cache[p] = []
559
+ return _abi_cache[p]
560
+
561
+ def get_pool_contract(pool_name: str):
562
+ cfg = POOLS[pool_name]
563
+ proxy = Web3.to_checksum_address(cfg["proxy"])
564
+ abi = get_pool_abi(cfg["proxy"])
565
+ w3 = get_w3()
566
+ return w3.eth.contract(address=proxy, abi=abi), cfg, w3
567
+
568
+ # Minimal Prime Account ABI: only the facet functions this tool calls. The diamond
569
+ # beacon's own ABI exposes beacon-management only, so the borrow/repay/fund and
570
+ # view selectors live in facets — we hand-pick the verified signatures here rather
571
+ # than enumerate 26 facet contracts at runtime.
572
+ # borrow/repay/fund: AssetsOperationsAvalancheFacet 0x5a501B5698eAdE321B3553eA633046c6a91E3763
573
+ # depositNativeToken: SmartLoanWrappedNativeTokenFacet 0x81252DF686542B1F353671458561DF8E9151c8C1
574
+ # getDebts/getBalance/getAllOwnedAssets: SmartLoanViewFacet 0x2B2C18F21A50c4DcbdFA54fb8cdC009F36AF27d9
575
+ # getHealthMeter: HealthMeterFacetProd 0x519AeEfC6558aD1f138E3892A09eBFC327eb67E2 (RedStone-gated)
576
+ # yakSwap/isWhitelistedAdapterOptimized: YieldYakSwapFacet 0x7b90769acaFb6540D00C06c406ba01Ab58B3028C (yakSwap is RedStone-gated)
577
+ # getHealthRatio/isSolvent/getTotalValue/getDebt: SolvencyFacetProdAvalanche 0x968f944e9c43FC8AD80F6C1629F10570a46e2651 (RedStone-gated)
578
+ # createWithdrawalIntent/executeWithdrawalIntent/getUserIntents/getAvailableBalance/getTotalIntentAmount:
579
+ # WithdrawalIntentFacet 0xf88f82e8982de4f7831B0A8BA55Ce23536872FD9 (executeWithdrawalIntent is RedStone-gated;
580
+ # the others are oracle-free; signatures verified on Snowtrace 23-05-2026)
581
+ PRIME_ACCOUNT_ABI = [
582
+ {"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
583
+ "name": "borrow", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
584
+ {"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
585
+ "name": "repay", "outputs": [], "stateMutability": "payable", "type": "function"},
586
+ {"inputs": [{"name": "_fundedAsset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
587
+ "name": "fund", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
588
+ {"inputs": [], "name": "depositNativeToken", "outputs": [],
589
+ "stateMutability": "payable", "type": "function"},
590
+ {"inputs": [], "name": "getAllOwnedAssets", "outputs": [{"type": "bytes32[]"}],
591
+ "stateMutability": "view", "type": "function"},
592
+ {"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getBalance",
593
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
594
+ {"inputs": [], "name": "getDebts",
595
+ "outputs": [{"components": [{"name": "name", "type": "bytes32"}, {"name": "debt", "type": "uint256"}], "type": "tuple[]"}],
596
+ "stateMutability": "view", "type": "function"},
597
+ {"inputs": [], "name": "getHealthMeter", "outputs": [{"type": "uint256"}],
598
+ "stateMutability": "view", "type": "function"},
599
+ {"inputs": [{"name": "_adapter", "type": "address"}], "name": "isWhitelistedAdapterOptimized",
600
+ "outputs": [{"type": "bool"}], "stateMutability": "view", "type": "function"},
601
+ {"inputs": [{"name": "_amountIn", "type": "uint256"}, {"name": "_amountOut", "type": "uint256"},
602
+ {"name": "_path", "type": "address[]"}, {"name": "_adapters", "type": "address[]"}],
603
+ "name": "yakSwap", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
604
+ # ParaSwapFacet.paraSwapV6 / SwapDebtFacet.swapDebtParaSwap — both RedStone-gated
605
+ # (remainsSolvent). selector+data are the ParaSwap Augustus calldata, split into its
606
+ # 4-byte method selector and the remaining ABI-encoded args. Signatures verified on
607
+ # Snowtrace 23-05-2026.
608
+ {"inputs": [{"name": "selector", "type": "bytes4"}, {"name": "data", "type": "bytes"}],
609
+ "name": "paraSwapV6", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
610
+ {"inputs": [{"name": "_fromAsset", "type": "bytes32"}, {"name": "_toAsset", "type": "bytes32"},
611
+ {"name": "_repayAmount", "type": "uint256"}, {"name": "_borrowAmount", "type": "uint256"},
612
+ {"name": "selector", "type": "bytes4"}, {"name": "data", "type": "bytes"}],
613
+ "name": "swapDebtParaSwap", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
614
+ {"inputs": [], "name": "owner", "outputs": [{"type": "address"}],
615
+ "stateMutability": "view", "type": "function"},
616
+ # SolvencyFacetProdAvalanche views — RedStone-gated. getTotalValue/getDebt are
617
+ # 1e18-scaled USD; getHealthRatio is 1e18-scaled (1e18 == liquidation line, so the
618
+ # human ratio is the raw value / 1e18). All revert with 0xe7764c9e on a bare
619
+ # eth_call — a signed RedStone price payload must be appended to the calldata.
620
+ {"inputs": [], "name": "getHealthRatio", "outputs": [{"type": "uint256"}],
621
+ "stateMutability": "view", "type": "function"},
622
+ {"inputs": [], "name": "getTotalValue", "outputs": [{"type": "uint256"}],
623
+ "stateMutability": "view", "type": "function"},
624
+ {"inputs": [], "name": "getDebt", "outputs": [{"type": "uint256"}],
625
+ "stateMutability": "view", "type": "function"},
626
+ {"inputs": [], "name": "isSolvent", "outputs": [{"type": "bool"}],
627
+ "stateMutability": "view", "type": "function"},
628
+ # getPrices: 1e8-scaled USD prices for the given symbols. RedStone-gated, so a payload
629
+ # is appended for the read. swap-debt uses it to value-match the borrow vs repay leg
630
+ # against the facet's own 5% cap (the facet calls the same view internally).
631
+ {"inputs": [{"name": "symbols", "type": "bytes32[]"}], "name": "getPrices",
632
+ "outputs": [{"type": "uint256[]"}], "stateMutability": "view", "type": "function"},
633
+ # WithdrawalIntentFacet — delayed collateral withdrawal. createWithdrawalIntent
634
+ # registers an intent (no RedStone); executeWithdrawalIntent pulls it to the EOA
635
+ # after maturity (RedStone-gated, also runs canRepayDebtFully). getUserIntents /
636
+ # getAvailableBalance / getTotalIntentAmount are oracle-free reads. IntentInfo's
637
+ # isActionable/isExpired flags make the 24h-72h window readable on-chain.
638
+ {"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "_amount", "type": "uint256"}],
639
+ "name": "createWithdrawalIntent", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
640
+ {"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "intentIndices", "type": "uint256[]"}],
641
+ "name": "executeWithdrawalIntent", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
642
+ {"inputs": [{"name": "_asset", "type": "bytes32"}, {"name": "intentIndex", "type": "uint256"}],
643
+ "name": "cancelWithdrawalIntent", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
644
+ {"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getUserIntents",
645
+ "outputs": [{"components": [{"name": "amount", "type": "uint256"},
646
+ {"name": "actionableAt", "type": "uint256"},
647
+ {"name": "expiresAt", "type": "uint256"},
648
+ {"name": "isPending", "type": "bool"},
649
+ {"name": "isActionable", "type": "bool"},
650
+ {"name": "isExpired", "type": "bool"}], "type": "tuple[]"}],
651
+ "stateMutability": "view", "type": "function"},
652
+ {"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getAvailableBalance",
653
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
654
+ {"inputs": [{"name": "_asset", "type": "bytes32"}], "name": "getTotalIntentAmount",
655
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
656
+ # ─── GMX V2 GM / GM+ LP (GmxV2FacetAvalanche / GmxV2PlusFacetAvalanche) ───
657
+ # All deposit/withdraw fns are PAYABLE and require executionFee == msg.value (the facet
658
+ # reverts InvalidExecutionFee otherwise). Two-sided deposits take a leading bool
659
+ # isLongToken (true = volatile leg, false = USDC); GM+ deposits omit it. Withdraws take
660
+ # gmAmount + min long/short token floors. Gated by an inline RedStone-priced solvency
661
+ # simulation (_getThresholdWeightedValuePayable/_getDebtPayable) + isWithinBounds, so
662
+ # --execute appends a signed price payload. getGmPerformance / getGmPlusPerformance and
663
+ # SmartLoanViewFacet.getGmTokenBalanceAfterFees read RedStone prices too (they revert
664
+ # 0xe7764c9e on a bare eth_call). Signatures verified on Snowtrace 23-05-2026.
665
+ {"inputs": [{"name": "isLongToken", "type": "bool"}, {"name": "tokenAmount", "type": "uint256"},
666
+ {"name": "minGmAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
667
+ "name": "depositAvaxUsdcGmxV2", "outputs": [], "stateMutability": "payable", "type": "function"},
668
+ {"inputs": [{"name": "isLongToken", "type": "bool"}, {"name": "tokenAmount", "type": "uint256"},
669
+ {"name": "minGmAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
670
+ "name": "depositBtcUsdcGmxV2", "outputs": [], "stateMutability": "payable", "type": "function"},
671
+ {"inputs": [{"name": "isLongToken", "type": "bool"}, {"name": "tokenAmount", "type": "uint256"},
672
+ {"name": "minGmAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
673
+ "name": "depositEthUsdcGmxV2", "outputs": [], "stateMutability": "payable", "type": "function"},
674
+ {"inputs": [{"name": "gmAmount", "type": "uint256"}, {"name": "minLongTokenAmount", "type": "uint256"},
675
+ {"name": "minShortTokenAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
676
+ "name": "withdrawAvaxUsdcGmxV2", "outputs": [], "stateMutability": "payable", "type": "function"},
677
+ {"inputs": [{"name": "gmAmount", "type": "uint256"}, {"name": "minLongTokenAmount", "type": "uint256"},
678
+ {"name": "minShortTokenAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
679
+ "name": "withdrawBtcUsdcGmxV2", "outputs": [], "stateMutability": "payable", "type": "function"},
680
+ {"inputs": [{"name": "gmAmount", "type": "uint256"}, {"name": "minLongTokenAmount", "type": "uint256"},
681
+ {"name": "minShortTokenAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
682
+ "name": "withdrawEthUsdcGmxV2", "outputs": [], "stateMutability": "payable", "type": "function"},
683
+ {"inputs": [{"name": "tokenAmount", "type": "uint256"}, {"name": "minGmAmount", "type": "uint256"},
684
+ {"name": "executionFee", "type": "uint256"}],
685
+ "name": "depositAvaxGmxV2Plus", "outputs": [], "stateMutability": "payable", "type": "function"},
686
+ {"inputs": [{"name": "tokenAmount", "type": "uint256"}, {"name": "minGmAmount", "type": "uint256"},
687
+ {"name": "executionFee", "type": "uint256"}],
688
+ "name": "depositBtcGmxV2Plus", "outputs": [], "stateMutability": "payable", "type": "function"},
689
+ {"inputs": [{"name": "tokenAmount", "type": "uint256"}, {"name": "minGmAmount", "type": "uint256"},
690
+ {"name": "executionFee", "type": "uint256"}],
691
+ "name": "depositEthGmxV2Plus", "outputs": [], "stateMutability": "payable", "type": "function"},
692
+ {"inputs": [{"name": "gmAmount", "type": "uint256"}, {"name": "minLongTokenAmount", "type": "uint256"},
693
+ {"name": "minShortTokenAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
694
+ "name": "withdrawAvaxGmxV2Plus", "outputs": [], "stateMutability": "payable", "type": "function"},
695
+ {"inputs": [{"name": "gmAmount", "type": "uint256"}, {"name": "minLongTokenAmount", "type": "uint256"},
696
+ {"name": "minShortTokenAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
697
+ "name": "withdrawBtcGmxV2Plus", "outputs": [], "stateMutability": "payable", "type": "function"},
698
+ {"inputs": [{"name": "gmAmount", "type": "uint256"}, {"name": "minLongTokenAmount", "type": "uint256"},
699
+ {"name": "minShortTokenAmount", "type": "uint256"}, {"name": "executionFee", "type": "uint256"}],
700
+ "name": "withdrawEthGmxV2Plus", "outputs": [], "stateMutability": "payable", "type": "function"},
701
+ {"inputs": [{"name": "gmToken", "type": "address"}], "name": "getGmPerformance",
702
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
703
+ {"inputs": [{"name": "gmToken", "type": "address"}], "name": "getGmPlusPerformance",
704
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
705
+ {"inputs": [{"name": "gmToken", "type": "address"}], "name": "getGmTokenBalanceAfterFees",
706
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
707
+ # ─── TraderJoe V2 Liquidity Book (TraderJoeV2AvalancheFacet) ──────────────
708
+ # Concentrated liquidity across discrete price bins. addLiquidityTraderJoeV2 carries
709
+ # remainsSolvent (RedStone-gated on --execute); removeLiquidityTraderJoeV2 does NOT
710
+ # (only onlyOwnerOrLiquidation/noBorrowInTheSameBlock) so it needs no payload.
711
+ # getOwnedTraderJoeV2Bins / getJoeV2RouterAddress are oracle-free views. The facet
712
+ # overrides LiquidityParameters.to/refundTo to the account itself and enforces
713
+ # maxBinsPerPrimeAccount()==80. Signatures verified on Snowtrace 23-05-2026 against the
714
+ # verified TraderJoeV2AvalancheFacet + ILBRouter source.
715
+ {"inputs": [{"name": "traderJoeV2Router", "type": "address"},
716
+ {"name": "liquidityParameters", "type": "tuple", "components": [
717
+ {"name": "tokenX", "type": "address"}, {"name": "tokenY", "type": "address"},
718
+ {"name": "binStep", "type": "uint256"}, {"name": "amountX", "type": "uint256"},
719
+ {"name": "amountY", "type": "uint256"}, {"name": "amountXMin", "type": "uint256"},
720
+ {"name": "amountYMin", "type": "uint256"}, {"name": "activeIdDesired", "type": "uint256"},
721
+ {"name": "idSlippage", "type": "uint256"}, {"name": "deltaIds", "type": "int256[]"},
722
+ {"name": "distributionX", "type": "uint256[]"}, {"name": "distributionY", "type": "uint256[]"},
723
+ {"name": "to", "type": "address"}, {"name": "refundTo", "type": "address"},
724
+ {"name": "deadline", "type": "uint256"}]}],
725
+ "name": "addLiquidityTraderJoeV2", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
726
+ {"inputs": [{"name": "traderJoeV2Router", "type": "address"},
727
+ {"name": "parameters", "type": "tuple", "components": [
728
+ {"name": "tokenX", "type": "address"}, {"name": "tokenY", "type": "address"},
729
+ {"name": "binStep", "type": "uint16"}, {"name": "amountXMin", "type": "uint256"},
730
+ {"name": "amountYMin", "type": "uint256"}, {"name": "ids", "type": "uint256[]"},
731
+ {"name": "amounts", "type": "uint256[]"}, {"name": "deadline", "type": "uint256"}]}],
732
+ "name": "removeLiquidityTraderJoeV2", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
733
+ {"inputs": [], "name": "getOwnedTraderJoeV2Bins",
734
+ "outputs": [{"components": [{"name": "pair", "type": "address"}, {"name": "id", "type": "uint24"}], "type": "tuple[]"}],
735
+ "stateMutability": "view", "type": "function"},
736
+ {"inputs": [], "name": "getJoeV2RouterAddress", "outputs": [{"type": "address"}],
737
+ "stateMutability": "view", "type": "function"},
738
+ # ─── sJOE staking (SJoeFacet) ─────────────────────────────────────────────
739
+ # stakeJoe / claimSJoeRewards carry remainsSolvent (RedStone-gated on --execute);
740
+ # unstakeJoe is onlyOwnerOrInsolvent (NOT remainsSolvent) so it needs no payload.
741
+ # joeBalanceInSJoe (staked JOE) and rewardsInSJoe (pending USDC) are oracle-free views.
742
+ # Signatures verified on Snowtrace 23-05-2026 against the verified SJoeFacet source.
743
+ {"inputs": [{"name": "amount", "type": "uint256"}], "name": "stakeJoe", "outputs": [],
744
+ "stateMutability": "nonpayable", "type": "function"},
745
+ {"inputs": [{"name": "amount", "type": "uint256"}], "name": "unstakeJoe", "outputs": [],
746
+ "stateMutability": "nonpayable", "type": "function"},
747
+ {"inputs": [], "name": "claimSJoeRewards", "outputs": [],
748
+ "stateMutability": "nonpayable", "type": "function"},
749
+ {"inputs": [], "name": "joeBalanceInSJoe", "outputs": [{"type": "uint256"}],
750
+ "stateMutability": "view", "type": "function"},
751
+ {"inputs": [], "name": "rewardsInSJoe", "outputs": [{"type": "uint256"}],
752
+ "stateMutability": "view", "type": "function"},
753
+ # ─── PRIME-token leverage tiers (PrimeLeverageFacet) ──────────────────────
754
+ # depositPrime carries remainsSolvent (RedStone-gated on --execute); the other writes
755
+ # (stake/activate, deactivate, unstake, repay) are onlyOwner only, so they need no
756
+ # payload. The four getters are oracle-free views. shouldLiquidatePrimeDebt is declared
757
+ # nonpayable because it MUTATES (snapshots debt) — we only eth_call it (read-only sim),
758
+ # never broadcast it. The LeverageTier enum is a uint8 on the wire (BASIC=0, PREMIUM=1).
759
+ # Signatures verified on Snowtrace 24-05-2026 against the verified PrimeLeverageFacet source.
760
+ {"inputs": [{"name": "_amount", "type": "uint256"}], "name": "depositPrime", "outputs": [],
761
+ "stateMutability": "nonpayable", "type": "function"},
762
+ {"inputs": [], "name": "stakePrimeAndActivatePremium", "outputs": [],
763
+ "stateMutability": "nonpayable", "type": "function"},
764
+ {"inputs": [{"name": "withdrawStake", "type": "bool"}], "name": "deactivatePremiumTier",
765
+ "outputs": [], "stateMutability": "nonpayable", "type": "function"},
766
+ {"inputs": [{"name": "amount", "type": "uint256"}], "name": "unstakePrime", "outputs": [],
767
+ "stateMutability": "nonpayable", "type": "function"},
768
+ {"inputs": [{"name": "amount", "type": "uint256"}], "name": "repayPrimeDebt", "outputs": [],
769
+ "stateMutability": "nonpayable", "type": "function"},
770
+ {"inputs": [], "name": "getLeverageTier", "outputs": [{"type": "uint8"}],
771
+ "stateMutability": "view", "type": "function"},
772
+ {"inputs": [], "name": "getLeverageTierFullInfo",
773
+ "outputs": [{"name": "currentTier", "type": "uint8"}, {"name": "stakedPrime", "type": "uint256"},
774
+ {"name": "recordedDebt", "type": "uint256"}],
775
+ "stateMutability": "view", "type": "function"},
776
+ {"inputs": [], "name": "getPrimeStakedAmount", "outputs": [{"type": "uint256"}],
777
+ "stateMutability": "view", "type": "function"},
778
+ {"inputs": [{"name": "tier", "type": "uint8"}, {"name": "borrowedValue", "type": "uint256"}],
779
+ "name": "getRequiredPrimeStake", "outputs": [{"type": "uint256"}],
780
+ "stateMutability": "view", "type": "function"},
781
+ {"inputs": [], "name": "shouldLiquidatePrimeDebt", "outputs": [{"type": "bool"}],
782
+ "stateMutability": "nonpayable", "type": "function"},
783
+ ]
784
+
785
+ # GMX DataStore: getUint(bytes32 key) holds the gas-limit params for fee estimation.
786
+ GMX_DATASTORE_ABI = [
787
+ {"inputs": [{"name": "key", "type": "bytes32"}], "name": "getUint",
788
+ "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"},
789
+ ]
790
+
791
+ # YieldYak router findBestPath. Returns a FormattedOffer struct
792
+ # (uint256[] amounts, address[] adapters, address[] path, uint256 gasEstimate).
793
+ YAK_ROUTER_ABI = [
794
+ {"inputs": [{"name": "_amountIn", "type": "uint256"}, {"name": "_tokenIn", "type": "address"},
795
+ {"name": "_tokenOut", "type": "address"}, {"name": "_maxSteps", "type": "uint256"}],
796
+ "name": "findBestPath",
797
+ "outputs": [{"components": [{"name": "amounts", "type": "uint256[]"},
798
+ {"name": "adapters", "type": "address[]"},
799
+ {"name": "path", "type": "address[]"},
800
+ {"name": "gasEstimate", "type": "uint256"}], "type": "tuple"}],
801
+ "stateMutability": "view", "type": "function"},
802
+ ]
803
+
804
+ # Tool asset symbol -> (token address, decimals). Swap operates on these. Same set as
805
+ # the lending pools; the symbol is the bytes32 the Prime Account uses, the address is
806
+ # the underlying ERC20 the YieldYak router routes on.
807
+ SWAP_ASSETS = {cfg["symbol"]: {"token": cfg["token"], "decimals": cfg["decimals"]}
808
+ for cfg in POOLS.values()}
809
+
810
+ def get_factory_contract(w3):
811
+ abi = get_pool_abi(FACTORY_PROXY) # proxy->impl resolution via the ABI cache
812
+ if not abi:
813
+ raise RuntimeError("Could not fetch SmartLoansFactory ABI from Snowtrace")
814
+ return w3.eth.contract(address=Web3.to_checksum_address(FACTORY_PROXY), abi=abi)
815
+
816
+ def get_prime_account(w3, owner: str) -> str:
817
+ """Owner -> Prime Account address. Zero address means none exists yet."""
818
+ pa = get_factory_contract(w3).functions.getLoanForOwner(Web3.to_checksum_address(owner)).call()
819
+ return None if int(pa, 16) == 0 else pa
820
+
821
+ def asset_b32(symbol: str) -> bytes:
822
+ return symbol.encode().ljust(32, b"\x00")
823
+
824
+ def pool_to_asset_symbol(pool_name: str) -> str:
825
+ """Pool key -> on-chain bytes32 asset symbol (the contracts use 'AVAX', not 'WAVAX')."""
826
+ return POOLS[pool_name]["symbol"]
827
+
828
+ def token_price(symbol: str) -> float:
829
+ """Price from KuCoin."""
830
+ try:
831
+ r = requests.get(f"https://api.kucoin.com/api/v1/market/orderbook/level1?symbol={symbol}-USDT", timeout=3)
832
+ if r.status_code == 200 and r.json().get("code") == "200000":
833
+ return float(r.json()["data"]["price"])
834
+ except: pass
835
+ return 0.0
836
+
837
+ # ─── RedStone on-demand price wrapping ───────────────────────────────────────
838
+ # DeltaPrime's Prime Account uses RedStone's on-demand model: signed price packages
839
+ # are fetched off-chain and APPENDED to the function calldata (after the normal
840
+ # ABI-encoded args). The solvency math (remainsSolvent modifier, and oracle views)
841
+ # parses them from the calldata tail, verifies the signatures, and aggregates by
842
+ # median. Without the payload these calls revert with 0xe7764c9e.
843
+ #
844
+ # Payload layout (matches @redstone-finance/evm-connector, verified against the
845
+ # deployed SolvencyFacetProdAvalanche source). Each signed data package:
846
+ # for each data point: symbol(bytes32) ++ value(uint256, scaled 1e8, big-endian)
847
+ # trailer: timestamp_ms(6) ++ dataPointValueByteSize(4)=32 ++ dataPointsCount(3)
848
+ # signature(65): r ++ s ++ v
849
+ # After all packages: dataPackagesCount(2) ++ unsignedMetadataSize(3)=0 ++ marker(9).
850
+ # The signed message a signer signs is exactly (dataPoints ++ trailer); keccak256 of
851
+ # that (no EIP-191 prefix) recovers the signer address.
852
+ #
853
+ # The value MUST be reconstructed exactly as RedStone signed it: parseUnits(toFixed(8), 8).
854
+ # Python float is the same IEEE-754 double as JS Number, so Decimal(float).quantize(1e-8,
855
+ # ROUND_HALF_UP) reproduces toFixed(8) byte-for-byte. The old int(round(value*1e8)) added a
856
+ # second float + banker's rounding, so half-boundary/high-precision values re-derived a WRONG
857
+ # body; the contract then ecrecovered a GARBAGE address and reverted SignerNotAuthorised
858
+ # (0xec459bc0, wrapped in 0xfd36fde3) with a different bogus signer each call (24-05-2026).
859
+ # Verified 2340/2340 signer matches with Decimal vs intermittent misses with round(). This
860
+ # affected EVERY RedStone-gated path (lending, swaps, GMX, LB, PRIME, solvency views), not
861
+ # just PRIME — it just surfaced on PRIME activation.
862
+
863
+ # Authorised redstone-primary-prod signers (3-of-5). Verified by recovering every gateway
864
+ # package + a read-only eth_call clearing the signer revert. Re-verify if SignerNotAuthorised
865
+ # resurfaces — RedStone can rotate node keys.
866
+ REDSTONE_VALUE_DECIMALS = 8
867
+ # The 5 authorised signers from PrimaryProdDataServiceConsumerBase.getAuthorisedSignerIndex().
868
+ # Must match what's baked into the on-chain contract EXACTLY — any mismatch causes
869
+ # SignerNotAuthorised reverts on every solvency-gated operation.
870
+ # Stored lower-case because _redstone_package_signer returns checksummed addresses and the
871
+ # filter compares signer.lower() in this set.
872
+ REDSTONE_AUTHORISED_SIGNERS = {
873
+ "0x8bb8f32df04c8b654987daaed53d6b6091e3b774",
874
+ "0xdeb22f54738d54976c4c0fe5ce6d408e40d88499",
875
+ "0x51ce04be4b3e32572c4ec9135221d0691ba7d202",
876
+ "0xdd682daec5a90dd295d14da4b0bec9281017b5be",
877
+ "0x9c5ae89c4af6aa32ce58588dbaf90d18a855b6de",
878
+ }
879
+
880
+ def _redstone_fetch_packages() -> dict:
881
+ """Fetch the latest signed price packages from the RedStone gateway. Returns the
882
+ per-feed map: {feedSymbol: [package, ...]} with one package per signer."""
883
+ last_err = None
884
+ for gw in REDSTONE_GATEWAYS:
885
+ try:
886
+ r = requests.get(f"{gw}/data-packages/latest/{REDSTONE_DATA_SERVICE}", timeout=20)
887
+ if r.status_code == 200:
888
+ return r.json()
889
+ last_err = f"HTTP {r.status_code}"
890
+ except Exception as e:
891
+ last_err = e
892
+ raise RuntimeError(f"RedStone gateway fetch failed: {last_err}")
893
+
894
+ def _redstone_scaled_value(value) -> int:
895
+ """Reconstruct the signed uint256 exactly as RedStone does: parseUnits(Number(value)
896
+ .toFixed(8), 8). Decimal(float(value)) is the exact IEEE-754 double (same as JS
897
+ Number); quantizing to 1e-8 with ROUND_HALF_UP reproduces toFixed(8). Using plain
898
+ int(round(value*1e8)) double-rounds and re-derives a wrong body -> garbage ecrecover
899
+ -> SignerNotAuthorised on the stricter facets."""
900
+ d = Decimal(float(value)).quantize(Decimal(1).scaleb(-REDSTONE_VALUE_DECIMALS),
901
+ rounding=ROUND_HALF_UP)
902
+ return int((d * (10 ** REDSTONE_VALUE_DECIMALS)).to_integral_value())
903
+
904
+ def _redstone_encode_package(pkg: dict) -> bytes:
905
+ """Serialize one signed data package to the on-chain byte layout."""
906
+ data_points = pkg["dataPoints"]
907
+ ts = int(pkg["timestampMilliseconds"])
908
+ body = b""
909
+ for dp in data_points:
910
+ value_scaled = _redstone_scaled_value(dp["value"])
911
+ body += dp["dataFeedId"].encode().ljust(32, b"\x00") + value_scaled.to_bytes(32, "big")
912
+ body += ts.to_bytes(6, "big") + (32).to_bytes(4, "big") + len(data_points).to_bytes(3, "big")
913
+ return body + base64.b64decode(pkg["signature"])
914
+
915
+ def _redstone_package_signer(pkg: dict) -> str:
916
+ """Recover a package's signer: ecrecover over keccak256(body) (no EIP-191 prefix),
917
+ where body is the encoded package minus its trailing 65-byte signature."""
918
+ body = _redstone_encode_package(pkg)[:-65]
919
+ sig = base64.b64decode(pkg["signature"])
920
+ r = int.from_bytes(sig[0:32], "big")
921
+ s = int.from_bytes(sig[32:64], "big")
922
+ rec_id = sig[64] - 27 if sig[64] >= 27 else sig[64]
923
+ return eth_keys.Signature(vrs=(rec_id, r, s)).recover_public_key_from_msg_hash(
924
+ Web3.keccak(body)).to_checksum_address()
925
+
926
+ def build_redstone_payload(symbols: list) -> bytes:
927
+ """Build a RedStone calldata payload covering the given feed symbols. Recovers each
928
+ package's signer, keeps only RedStone's authorised set, then takes the first
929
+ REDSTONE_SIGNERS_THRESHOLD per feed (the contract needs that many unique authorised
930
+ signers per feed to aggregate a median). Filtering guards against the gateway ever
931
+ returning extra/standby signers and surfaces a clear error rather than an on-chain revert."""
932
+ gateway = _redstone_fetch_packages()
933
+ packages = []
934
+ for sym in symbols:
935
+ feed_packages = gateway.get(sym)
936
+ if not feed_packages:
937
+ raise RuntimeError(f"RedStone gateway has no feed for '{sym}'")
938
+ authorised = [p for p in feed_packages
939
+ if _redstone_package_signer(p).lower() in REDSTONE_AUTHORISED_SIGNERS]
940
+ if len(authorised) < REDSTONE_SIGNERS_THRESHOLD:
941
+ raise RuntimeError(
942
+ f"RedStone feed '{sym}' has only {len(authorised)} authorised signers "
943
+ f"(of {len(feed_packages)} returned), need {REDSTONE_SIGNERS_THRESHOLD}")
944
+ for pkg in authorised[:REDSTONE_SIGNERS_THRESHOLD]:
945
+ packages.append(_redstone_encode_package(pkg))
946
+ payload = b"".join(packages)
947
+ payload += len(packages).to_bytes(2, "big") # data packages count
948
+ payload += (0).to_bytes(3, "big") # unsigned metadata byte size = 0
949
+ payload += REDSTONE_MARKER
950
+ return payload
951
+
952
+ def prime_account_price_feeds(account) -> list:
953
+ """The set of RedStone feed symbols a solvency check on this account needs: the
954
+ native AVAX symbol, every owned asset, and every debt-registry asset. The solvency
955
+ math prices ALL debt-registry assets (getDebts() returns the full pool set, not
956
+ just non-zero balances), so every symbol it returns must be in the payload even at
957
+ zero debt — otherwise that feed shows 0 signers and the call reverts with
958
+ InsufficientNumberOfUniqueSigners. Deduped, AVAX first (priced as element 0)."""
959
+ feeds = ["AVAX"]
960
+ for a in account.functions.getAllOwnedAssets().call():
961
+ sym = a.rstrip(b"\x00").decode(errors="replace")
962
+ if sym and sym not in feeds:
963
+ feeds.append(sym)
964
+ for name, _debt in account.functions.getDebts().call():
965
+ sym = name.rstrip(b"\x00").decode(errors="replace")
966
+ if sym and sym not in feeds:
967
+ feeds.append(sym)
968
+ return feeds
969
+
970
+ def redstone_view_call(w3, account, fn_name: str, payload: bytes, args: list = None):
971
+ """Read-only call of a RedStone-gated view on the Prime Account. The signed price
972
+ payload is appended to the ABI-encoded calldata (same wrapping as a write tx), then
973
+ eth_call'd and the result decoded against the function's ABI. Used for the solvency
974
+ views (getHealthRatio/getTotalValue/getDebt/isSolvent, no args) and the GMX views
975
+ (getGm[Plus]Performance/getGmTokenBalanceAfterFees, one address arg), which revert with
976
+ 0xe7764c9e on a bare call. `payload` is reused across calls so the gateway is hit once."""
977
+ data = account.encode_abi(fn_name, args=args or []) + payload.hex()
978
+ raw = w3.eth.call({"to": account.address, "data": data})
979
+ fn_abi = next(f for f in PRIME_ACCOUNT_ABI if f.get("name") == fn_name)
980
+ out_types = [o["type"] for o in fn_abi["outputs"]]
981
+ return w3.codec.decode(out_types, bytes(raw))
982
+
983
+ # ─── Commands ──────────────────────────────────────────────────────────────
984
+
985
+ def cmd_pool_info(pool_name: str):
986
+ if pool_name == "all":
987
+ for name in POOLS:
988
+ cmd_pool_info(name)
989
+ print()
990
+ return
991
+
992
+ contract, cfg, w3 = get_pool_contract(pool_name)
993
+ p = cfg["proxy"][:12]
994
+ d = cfg["decimals"]
995
+
996
+ ts = contract.functions.totalSupply().call()
997
+ tb = contract.functions.totalBorrowed().call()
998
+ print(f"=== {cfg['symbol']} Pool ({p}...) ===")
999
+ print(f" Total Supply: {ts / 10**d:>14,.2f} {cfg['symbol']}")
1000
+ print(f" Total Borrowed: {tb / 10**d:>14,.2f} {cfg['symbol']}")
1001
+ util = tb / ts * 100 if ts > 0 else 0
1002
+ print(f" Utilization: {util:>14.2f}%")
1003
+ price = token_price(cfg["symbol"])
1004
+ if price:
1005
+ print(f" Token Price: ${price:>13,.2f}")
1006
+ print(f" TVL: ${ts / 10**d * price:>13,.2f}")
1007
+
1008
+ # Show the signer's pool deposit when a key is configured; pool-info should
1009
+ # also work as a pure read-only command without one.
1010
+ try:
1011
+ acct = get_account()
1012
+ except RuntimeError:
1013
+ return
1014
+ my_bal = contract.functions.balanceOf(acct.address).call()
1015
+ if my_bal > 0:
1016
+ print(f" My Deposit: {my_bal / 10**d:.4f} {cfg['symbol']}")
1017
+
1018
+ def cmd_my_positions():
1019
+ acct = get_account()
1020
+ w3 = get_w3()
1021
+ print(f"Wallet: {acct.address}")
1022
+
1023
+ # Wallet AVAX
1024
+ avax = w3.eth.get_balance(acct.address) / 1e18
1025
+ print(f"AVAX: {avax:.6f}")
1026
+
1027
+ # Wallet PRIME (not a pool token; shown so it's detected/displayed in the wallet view)
1028
+ try:
1029
+ prime_bal = _prime_token_contract(w3).functions.balanceOf(acct.address).call()
1030
+ if prime_bal > 0:
1031
+ print(f" Wallet PRIME: {prime_bal / 10**PRIME_TOKEN['decimals']:.6f}")
1032
+ except Exception:
1033
+ pass
1034
+
1035
+ # Check each pool
1036
+ for name, cfg in POOLS.items():
1037
+ try:
1038
+ contract, _, _ = get_pool_contract(name)
1039
+ token_addr = Web3.to_checksum_address(cfg["token"])
1040
+ tok_abi = get_pool_abi(cfg["token"]) if cfg["proxy"] != cfg["token"] else []
1041
+ if not tok_abi:
1042
+ # Use minimal ERC20 ABI for balance check
1043
+ tok_abi = json.loads('[{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"type":"function"}]')
1044
+ token = w3.eth.contract(address=token_addr, abi=tok_abi)
1045
+ bal = token.functions.balanceOf(acct.address).call()
1046
+ if bal > 0:
1047
+ print(f" Wallet {cfg['symbol']}: {bal / 10**cfg['decimals']:.4f}")
1048
+
1049
+ # Check pool deposit
1050
+ pool_bal = contract.functions.balanceOf(acct.address).call()
1051
+ if pool_bal > 0:
1052
+ print(f" Pool Deposit {cfg['symbol']}: {pool_bal / 10**cfg['decimals']:.4f}")
1053
+
1054
+ # Check borrow
1055
+ borrowed = contract.functions.getBorrowed(acct.address).call()
1056
+ if borrowed > 0:
1057
+ print(f" Borrowed {cfg['symbol']}: {borrowed / 10**cfg['decimals']:.4f}")
1058
+
1059
+ except Exception as e:
1060
+ print(f" {name}: {e}")
1061
+
1062
+ # Prime Account (via getLoanForOwner — the factory has no getAccount())
1063
+ try:
1064
+ pa = get_prime_account(w3, acct.address)
1065
+ if pa:
1066
+ print(f"\nPrime Account: {pa}")
1067
+ pa_avax = w3.eth.get_balance(Web3.to_checksum_address(pa)) / 1e18
1068
+ print(f" AVAX balance: {pa_avax:.6f}")
1069
+ else:
1070
+ print("\nNo Prime Account yet. Create with: deltaprime create-prime-account --execute")
1071
+ except Exception as e:
1072
+ print(f"\nPrime Account lookup failed: {e}")
1073
+
1074
+ def cmd_deposit(pool_name: str, amount: float, execute: bool = False):
1075
+ contract, cfg, w3 = get_pool_contract(pool_name)
1076
+ acct = get_account()
1077
+ amount_wei = int(amount * 10**cfg["decimals"])
1078
+
1079
+ if not execute:
1080
+ print(f"Preview: Deposit {amount} {cfg['symbol']} into {pool_name.upper()} pool")
1081
+ print("Run with --execute to broadcast")
1082
+ return
1083
+
1084
+ if cfg["native"]:
1085
+ tx = contract.functions.deposit(amount_wei).build_transaction({
1086
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1087
+ "gas": 200000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID, "value": amount_wei,
1088
+ })
1089
+ signed = acct.sign_transaction(tx)
1090
+ else:
1091
+ # Approve
1092
+ token = w3.eth.contract(address=Web3.to_checksum_address(cfg["token"]),
1093
+ abi=json.loads('[{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"type":"function"}]'))
1094
+ app_tx = token.functions.approve(Web3.to_checksum_address(cfg["proxy"]), amount_wei).build_transaction({
1095
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1096
+ "gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1097
+ })
1098
+ signed_app = acct.sign_transaction(app_tx)
1099
+ w3.eth.send_raw_transaction(signed_app.raw_transaction)
1100
+
1101
+ # Deposit
1102
+ dep_tx = contract.functions.deposit(amount_wei).build_transaction({
1103
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1104
+ "gas": 200000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1105
+ })
1106
+ signed = acct.sign_transaction(dep_tx)
1107
+
1108
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1109
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
1110
+ ok = receipt["status"] == 1
1111
+ print(f"{'✓' if ok else '✗'} Deposit {amount} {cfg['symbol']} {'confirmed' if ok else 'failed'}")
1112
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1113
+
1114
+ def cmd_withdraw(pool_name: str, amount: float, execute: bool = False):
1115
+ contract, cfg, w3 = get_pool_contract(pool_name)
1116
+ acct = get_account()
1117
+ amount_wei = int(amount * 10**cfg["decimals"])
1118
+
1119
+ if not execute:
1120
+ print(f"Preview: Withdraw {amount} {cfg['symbol']} from {pool_name.upper()} pool")
1121
+ print("Run with --execute to broadcast")
1122
+ return
1123
+
1124
+ tx = contract.functions.withdraw(amount_wei).build_transaction({
1125
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1126
+ "gas": 200000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1127
+ })
1128
+ signed = acct.sign_transaction(tx)
1129
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1130
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
1131
+ ok = receipt["status"] == 1
1132
+ print(f"{'✓' if ok else '✗'} Withdraw {amount} {cfg['symbol']} {'confirmed' if ok else 'failed'}")
1133
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1134
+
1135
+ # ─── Prime Account commands ──────────────────────────────────────────────────
1136
+
1137
+ # bytes32 asset symbol -> decimals. The Prime Account can hold assets beyond the
1138
+ # five lending pools; fall back to 18 (the EVM default) for anything unmapped.
1139
+ _ASSET_DECIMALS = {cfg["symbol"]: cfg["decimals"] for cfg in POOLS.values()}
1140
+
1141
+ def _asset_decimals(symbol: str) -> int:
1142
+ return _ASSET_DECIMALS.get(symbol, 18)
1143
+
1144
+ def cmd_create_prime_account(execute: bool = False, fund_pool: str = None, fund_amount: float = None):
1145
+ """Create a Prime Account. With fund_pool/fund_amount, create and fund in one
1146
+ tx via SmartLoansFactory.createAndFundLoan(bytes32 asset, amount) — ERC20 only,
1147
+ and the factory pulls the asset via transferFrom so it needs a prior approve to
1148
+ the factory. Without fund args, plain createLoan() makes an empty account."""
1149
+ w3 = get_w3()
1150
+ acct = get_account()
1151
+ existing = get_prime_account(w3, acct.address)
1152
+ if existing:
1153
+ print(f"Prime Account already exists: {existing}")
1154
+ print("Nothing to create. Fund it with: deltaprime fund --pool <p> --amount <n> --execute")
1155
+ return
1156
+
1157
+ funding = fund_pool is not None and fund_amount is not None
1158
+ cfg = POOLS[fund_pool] if funding else None
1159
+ if funding and cfg["native"]:
1160
+ print("createAndFundLoan is ERC20-only — it cannot wrap native AVAX.")
1161
+ print("For an AVAX-funded account: create-prime-account --execute, then")
1162
+ print(" fund --pool wavax --amount <n> --execute (uses depositNativeToken()).")
1163
+ return
1164
+
1165
+ factory = get_factory_contract(w3)
1166
+ factory_cs = Web3.to_checksum_address(FACTORY_PROXY)
1167
+
1168
+ if not execute:
1169
+ print(f"Preview: Create a new Prime Account for {acct.address}")
1170
+ if funding:
1171
+ symbol = cfg["symbol"]
1172
+ amount_wei = int(fund_amount * 10**cfg["decimals"])
1173
+ print(f" Factory: {FACTORY_PROXY} (SmartLoansFactory.createAndFundLoan())")
1174
+ print(f" Approves the factory to spend {fund_amount} {symbol}, then")
1175
+ print(f" calls createAndFundLoan(bytes32 '{symbol}', {amount_wei}) — creates + funds in one go.")
1176
+ print(" Wallet must hold enough of the asset.")
1177
+ else:
1178
+ print(f" Factory: {FACTORY_PROXY} (SmartLoansFactory.createLoan())")
1179
+ print(" Creates an empty account; fund it afterwards before borrowing.")
1180
+ print("Run with --execute to broadcast")
1181
+ return
1182
+
1183
+ if funding:
1184
+ symbol = cfg["symbol"]
1185
+ amount_wei = int(fund_amount * 10**cfg["decimals"])
1186
+ # createAndFundLoan does token.transferFrom(msg.sender, factory, amount),
1187
+ # so approve the factory first.
1188
+ token = w3.eth.contract(address=Web3.to_checksum_address(cfg["token"]),
1189
+ abi=json.loads('[{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"type":"function"}]'))
1190
+ app_tx = token.functions.approve(factory_cs, amount_wei).build_transaction({
1191
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1192
+ "gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1193
+ })
1194
+ signed_app = acct.sign_transaction(app_tx)
1195
+ w3.eth.send_raw_transaction(signed_app.raw_transaction)
1196
+
1197
+ tx = factory.functions.createAndFundLoan(asset_b32(symbol), amount_wei).build_transaction({
1198
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1199
+ "gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1200
+ })
1201
+ else:
1202
+ tx = factory.functions.createLoan().build_transaction({
1203
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1204
+ "gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1205
+ })
1206
+ signed = acct.sign_transaction(tx)
1207
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1208
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1209
+ ok = receipt["status"] == 1
1210
+ label = "Create+fund Prime Account" if funding else "Create Prime Account"
1211
+ print(f"{'✓' if ok else '✗'} {label} {'confirmed' if ok else 'failed'}")
1212
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1213
+ if ok:
1214
+ # getLoanForOwner can lag a beat behind the receipt; poll briefly so we
1215
+ # print the new account address instead of None right after creation.
1216
+ pa = None
1217
+ for _ in range(6):
1218
+ pa = get_prime_account(w3, acct.address)
1219
+ if pa:
1220
+ break
1221
+ time.sleep(2)
1222
+ if pa:
1223
+ print(f" Prime Account: {pa}")
1224
+ else:
1225
+ print(" Prime Account: created — getLoanForOwner not propagated yet, run 'my-positions' shortly.")
1226
+
1227
+ def cmd_fund(pool_name: str, amount: float, execute: bool = False):
1228
+ """Fund collateral from the EOA wallet into its Prime Account.
1229
+
1230
+ ERC20 assets: approve the Prime Account to spend the token, then call
1231
+ fund(bytes32 asset, amount) on it. Native AVAX (wavax pool): call the
1232
+ payable depositNativeToken() and send AVAX as msg.value — the account
1233
+ wraps AVAX->WAVAX internally, so no token approve is needed.
1234
+ """
1235
+ cfg = POOLS[pool_name]
1236
+ w3 = get_w3()
1237
+ acct = get_account()
1238
+ pa = get_prime_account(w3, acct.address)
1239
+ if not pa:
1240
+ print("No Prime Account. Create one first: deltaprime create-prime-account --execute")
1241
+ return
1242
+
1243
+ symbol = pool_to_asset_symbol(pool_name)
1244
+ amount_wei = int(amount * 10**cfg["decimals"])
1245
+ pa_cs = Web3.to_checksum_address(pa)
1246
+
1247
+ if not execute:
1248
+ print(f"Preview: Fund {amount} {symbol} into Prime Account {pa}")
1249
+ if cfg["native"]:
1250
+ print(f" Native AVAX: calls depositNativeToken() with value={amount_wei} wei")
1251
+ print(" Wraps AVAX->WAVAX inside the account; no token approval needed.")
1252
+ else:
1253
+ print(f" Approves {pa} to spend {amount} {symbol}, then calls fund(bytes32 '{symbol}', {amount_wei})")
1254
+ print(" Wallet must hold enough of the asset.")
1255
+ print("Run with --execute to broadcast")
1256
+ return
1257
+
1258
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
1259
+ if cfg["native"]:
1260
+ tx = account.functions.depositNativeToken().build_transaction({
1261
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1262
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID, "value": amount_wei,
1263
+ })
1264
+ signed = acct.sign_transaction(tx)
1265
+ else:
1266
+ token = w3.eth.contract(address=Web3.to_checksum_address(cfg["token"]),
1267
+ abi=json.loads('[{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"type":"function"}]'))
1268
+ app_tx = token.functions.approve(pa_cs, amount_wei).build_transaction({
1269
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1270
+ "gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1271
+ })
1272
+ signed_app = acct.sign_transaction(app_tx)
1273
+ w3.eth.send_raw_transaction(signed_app.raw_transaction)
1274
+
1275
+ fund_tx = account.functions.fund(asset_b32(symbol), amount_wei).build_transaction({
1276
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1277
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1278
+ })
1279
+ signed = acct.sign_transaction(fund_tx)
1280
+
1281
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1282
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1283
+ ok = receipt["status"] == 1
1284
+ print(f"{'✓' if ok else '✗'} Fund {amount} {symbol} {'confirmed' if ok else 'failed'}")
1285
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1286
+ return ok
1287
+
1288
+ def _prices_usd(w3, account, symbols: list, payload: bytes) -> dict:
1289
+ """Best-effort per-symbol USD price map via the RedStone-gated getPrices view (1e8-scaled).
1290
+ Reuses an already-built `payload`; returns {symbol: float}. Any symbol whose feed is
1291
+ missing from the payload is omitted (callers treat a missing entry as usd=None) rather
1292
+ than failing the whole readout."""
1293
+ syms = [s for s in dict.fromkeys(symbols) if s]
1294
+ if not syms:
1295
+ return {}
1296
+ try:
1297
+ raw = redstone_view_call(w3, account, "getPrices", payload,
1298
+ args=[[asset_b32(s) for s in syms]])[0]
1299
+ return {s: raw[i] / 1e8 for i, s in enumerate(syms)}
1300
+ except Exception:
1301
+ return {}
1302
+
1303
+ def gather_lending(w3, account):
1304
+ """Read-only lending/leverage data for a Prime Account: in-account collateral
1305
+ (getAllOwnedAssets/getBalance), debts (getDebts), and RedStone-gated solvency
1306
+ (getTotalValue/getDebt/getHealthRatio/isSolvent) plus best-effort per-asset USD via
1307
+ getPrices. Shared by cmd_prime_summary (print) and cmd_defi (--json). Solvency fields
1308
+ fall back to None if the RedStone gateway is unreachable or a view reverts."""
1309
+ owned = [a.rstrip(b"\x00").decode(errors="replace") for a in account.functions.getAllOwnedAssets().call()]
1310
+ supplied = []
1311
+ for sym in owned:
1312
+ bal = account.functions.getBalance(asset_b32(sym)).call()
1313
+ supplied.append({"symbol": sym, "raw": bal, "decimals": _asset_decimals(sym),
1314
+ "balance": f"{bal / 10**_asset_decimals(sym):.6f}"})
1315
+ borrowed = []
1316
+ for n, v in account.functions.getDebts().call():
1317
+ sym = n.rstrip(b"\x00").decode(errors="replace")
1318
+ if v > 0:
1319
+ borrowed.append({"symbol": sym, "raw": v, "decimals": _asset_decimals(sym),
1320
+ "balance": f"{v / 10**_asset_decimals(sym):.6f}"})
1321
+ out = {"supplied": supplied, "borrowed": borrowed,
1322
+ "total_value_usd": None, "debt_usd": None, "health_ratio": None, "solvent": None}
1323
+ try:
1324
+ payload = build_redstone_payload(prime_account_price_feeds(account))
1325
+ out["total_value_usd"] = redstone_view_call(w3, account, "getTotalValue", payload)[0] / 1e18
1326
+ out["debt_usd"] = redstone_view_call(w3, account, "getDebt", payload)[0] / 1e18
1327
+ ratio = redstone_view_call(w3, account, "getHealthRatio", payload)[0] / 1e18
1328
+ # With no/negligible debt the ratio is astronomically large (e.g. 1e59) — meaningless
1329
+ # to render. Surface it as None so consumers show "no debt" instead of a junk number.
1330
+ out["health_ratio"] = None if ratio > 1000 else ratio
1331
+ out["solvent"] = bool(redstone_view_call(w3, account, "isSolvent", payload)[0])
1332
+ prices = _prices_usd(w3, account, [r["symbol"] for r in supplied + borrowed], payload)
1333
+ for r in supplied + borrowed:
1334
+ p = prices.get(r["symbol"])
1335
+ r["usd"] = (r["raw"] / 10**r["decimals"] * p) if p is not None else None
1336
+ except Exception as e:
1337
+ out["solvency_error"] = type(e).__name__
1338
+ for r in supplied + borrowed:
1339
+ r["usd"] = None
1340
+ return out
1341
+
1342
+ def cmd_prime_summary():
1343
+ w3 = get_w3()
1344
+ acct = get_account()
1345
+ pa = get_prime_account(w3, acct.address)
1346
+ print(f"Owner wallet: {acct.address}")
1347
+ if not pa:
1348
+ print("No Prime Account yet. Create one with: deltaprime create-prime-account --execute")
1349
+ return
1350
+
1351
+ print(f"Prime Account: {pa}")
1352
+ pa_avax = w3.eth.get_balance(pa) / 1e18
1353
+ print(f" Native AVAX (gas): {pa_avax:.6f}")
1354
+
1355
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
1356
+ data = gather_lending(w3, account)
1357
+
1358
+ print(" Assets:")
1359
+ if data["supplied"]:
1360
+ for r in data["supplied"]:
1361
+ print(f" {r['symbol']:<8} {float(r['balance']):,.6f}")
1362
+ else:
1363
+ print(" (none)")
1364
+
1365
+ print(" Debts:")
1366
+ if data["borrowed"]:
1367
+ for r in data["borrowed"]:
1368
+ print(f" {r['symbol']:<8} {float(r['balance']):,.6f}")
1369
+ else:
1370
+ print(" (none)")
1371
+
1372
+ # Solvency views (SolvencyFacetProdAvalanche) are RedStone-gated: they revert
1373
+ # (0xe7764c9e) without signed price calldata appended. gather_lending fetches a fresh
1374
+ # RedStone payload covering every feed the solvency math touches and eth_calls the views
1375
+ # with it appended — no tx, read-only. getTotalValue/getDebt are 1e18-scaled USD;
1376
+ # getHealthRatio is 1e18-scaled where 1.0 is the liquidation line. Falls back to the old
1377
+ # note if the gateway is unreachable or a view reverts.
1378
+ if "solvency_error" not in data:
1379
+ print(f" Total value: ${data['total_value_usd']:,.2f}")
1380
+ print(f" Debt: ${data['debt_usd']:,.2f}")
1381
+ ratio = data["health_ratio"]
1382
+ # gather_lending nulls the ratio when debt is negligible (the raw value is
1383
+ # astronomically large there); render that as ">1000" rather than a junk number.
1384
+ ratio_str = ">1000.00 (negligible debt)" if ratio is None else f"{ratio:.4f}"
1385
+ print(f" Health ratio: {ratio_str} (>1.0 = solvent)")
1386
+ print(f" Solvent: {'yes' if data['solvent'] else 'NO — liquidatable'}")
1387
+ else:
1388
+ print(f" Health/solvency: RedStone fetch/call failed ({data.get('solvency_error', 'error')}); "
1389
+ "showing balances only")
1390
+
1391
+ def cmd_borrow(pool_name: str, amount: float, execute: bool = False):
1392
+ cfg = POOLS[pool_name]
1393
+ w3 = get_w3()
1394
+ acct = get_account()
1395
+ pa = get_prime_account(w3, acct.address)
1396
+ if not pa:
1397
+ print("No Prime Account. Create one first: deltaprime create-prime-account --execute")
1398
+ return
1399
+
1400
+ symbol = pool_to_asset_symbol(pool_name)
1401
+ amount_wei = int(amount * 10**cfg["decimals"])
1402
+ if not execute:
1403
+ print(f"Preview: Borrow {amount} {symbol} into Prime Account {pa}")
1404
+ print(f" Calls borrow(bytes32 '{symbol}', {amount_wei}) on the Prime Account")
1405
+ print(" Requires sufficient collateral funded into the account.")
1406
+ print("Run with --execute to broadcast")
1407
+ return
1408
+
1409
+ pa_cs = Web3.to_checksum_address(pa)
1410
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
1411
+ # borrow has remainsSolvent → needs RedStone price payload appended to calldata
1412
+ feeds = prime_account_price_feeds(account)
1413
+ if symbol not in feeds:
1414
+ feeds.append(symbol)
1415
+ payload = build_redstone_payload(feeds)
1416
+ base_calldata = account.encode_abi("borrow", args=[asset_b32(symbol), amount_wei])
1417
+ data = base_calldata + payload.hex()
1418
+ tx = {
1419
+ "from": acct.address, "to": pa_cs, "data": data,
1420
+ "nonce": w3.eth.get_transaction_count(acct.address),
1421
+ "gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1422
+ }
1423
+ signed = acct.sign_transaction(tx)
1424
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1425
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1426
+ ok = receipt["status"] == 1
1427
+ print(f"{'✓' if ok else '✗'} Borrow {amount} {symbol} {'confirmed' if ok else 'failed'}")
1428
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1429
+ return ok
1430
+
1431
+ def cmd_repay(pool_name: str, amount: float, execute: bool = False):
1432
+ cfg = POOLS[pool_name]
1433
+ w3 = get_w3()
1434
+ acct = get_account()
1435
+ pa = get_prime_account(w3, acct.address)
1436
+ if not pa:
1437
+ print("No Prime Account. Create one first: deltaprime create-prime-account --execute")
1438
+ return
1439
+
1440
+ symbol = pool_to_asset_symbol(pool_name)
1441
+ pa_cs = Web3.to_checksum_address(pa)
1442
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
1443
+ pool, _, _ = get_pool_contract(pool_name)
1444
+ # The facet's repay reverts if amount > debt OR amount > in-account balance.
1445
+ # Cap to min(requested, debt, in_account) so callers don't need to know either
1446
+ # exact figure — pass an overshoot like 9999 and it clips cleanly.
1447
+ requested_wei = int(amount * 10**cfg["decimals"])
1448
+ debt_wei = pool.functions.getBorrowed(pa_cs).call()
1449
+ in_acct_wei = account.functions.getBalance(asset_b32(symbol)).call()
1450
+ if debt_wei == 0:
1451
+ print(f"No {symbol} debt to repay on Prime Account {pa}.")
1452
+ return
1453
+ amount_wei = min(requested_wei, debt_wei, in_acct_wei)
1454
+ if amount_wei == 0:
1455
+ print(f"Repay {amount} {symbol}: in-account {symbol} balance is 0 — "
1456
+ f"swap into {symbol} first (e.g. deltaprime swap --to {symbol} --amount N --execute).")
1457
+ return
1458
+ cap_notes = []
1459
+ if amount_wei < requested_wei:
1460
+ if in_acct_wei < min(requested_wei, debt_wei):
1461
+ cap_notes.append(f"in-account {symbol} only {in_acct_wei / 10**cfg['decimals']:.6f}")
1462
+ if debt_wei < requested_wei:
1463
+ cap_notes.append(f"debt only {debt_wei / 10**cfg['decimals']:.6f} {symbol}")
1464
+
1465
+ if not execute:
1466
+ print(f"Preview: Repay {amount_wei / 10**cfg['decimals']:.6f} {symbol} from Prime Account {pa}")
1467
+ if cap_notes:
1468
+ print(f" Capped from requested {amount}: {'; '.join(cap_notes)}")
1469
+ print(f" Calls repay(bytes32 '{symbol}', {amount_wei}) on the Prime Account")
1470
+ print(f" Current debt: {debt_wei / 10**cfg['decimals']:.6f} {symbol} | "
1471
+ f"in-account: {in_acct_wei / 10**cfg['decimals']:.6f} {symbol}")
1472
+ if in_acct_wei < debt_wei:
1473
+ shortfall = (debt_wei - in_acct_wei) / 10**cfg['decimals']
1474
+ print(f" Note: in-account < debt by {shortfall:.6f} {symbol} — "
1475
+ f"swap into {symbol} first to close the position fully.")
1476
+ print("Run with --execute to broadcast")
1477
+ return
1478
+
1479
+ if cap_notes:
1480
+ print(f" Capped requested {amount} {symbol} to {amount_wei / 10**cfg['decimals']:.6f} "
1481
+ f"({'; '.join(cap_notes)}).")
1482
+ # repay calls _isSolvent() which uses proxyDelegateCalldata → needs RedStone payload
1483
+ feeds = prime_account_price_feeds(account)
1484
+ if symbol not in feeds:
1485
+ feeds.append(symbol)
1486
+ payload = build_redstone_payload(feeds)
1487
+ base_calldata = account.encode_abi("repay", args=[asset_b32(symbol), amount_wei])
1488
+ data = base_calldata + payload.hex()
1489
+ tx = {
1490
+ "from": acct.address, "to": pa_cs, "data": data,
1491
+ "nonce": w3.eth.get_transaction_count(acct.address),
1492
+ "gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1493
+ }
1494
+ signed = acct.sign_transaction(tx)
1495
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1496
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1497
+ ok = receipt["status"] == 1
1498
+ repaid = amount_wei / 10**cfg['decimals']
1499
+ print(f"{'✓' if ok else '✗'} Repay {repaid:.6f} {symbol} {'confirmed' if ok else 'failed'}")
1500
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1501
+
1502
+ def _decode_formatted_offer(raw: bytes):
1503
+ """Manually decode YieldYak's FormattedOffer struct
1504
+ (uint256[] amounts, address[] adapters, address[] path, uint256 gasEstimate).
1505
+ Hand-rolled because eth-abi 6.0.0b1's validate_pointers rejects this otherwise
1506
+ valid encoding (the trailing inline uint256 confuses its pointer validator)."""
1507
+ base = 32 # offsets inside the struct are relative to the start of its body
1508
+ amounts_off = base + int.from_bytes(raw[32:64], "big")
1509
+ adapters_off = base + int.from_bytes(raw[64:96], "big")
1510
+ path_off = base + int.from_bytes(raw[96:128], "big")
1511
+
1512
+ def read_array(off, is_address):
1513
+ n = int.from_bytes(raw[off:off + 32], "big")
1514
+ out = []
1515
+ for i in range(n):
1516
+ word = raw[off + 32 + i * 32: off + 64 + i * 32]
1517
+ out.append(Web3.to_checksum_address(word[12:]) if is_address
1518
+ else int.from_bytes(word, "big"))
1519
+ return out
1520
+
1521
+ return read_array(amounts_off, False), read_array(adapters_off, True), read_array(path_off, True)
1522
+
1523
+ def _yak_find_best_path(w3, amount_in_wei: int, token_in: str, token_out: str, max_steps: int = 3):
1524
+ """Off-chain route lookup via the YieldYak router. Returns (amounts, adapters, path)."""
1525
+ router = w3.eth.contract(address=Web3.to_checksum_address(YAK_ROUTER), abi=YAK_ROUTER_ABI)
1526
+ data = router.encode_abi("findBestPath", args=[
1527
+ amount_in_wei, Web3.to_checksum_address(token_in),
1528
+ Web3.to_checksum_address(token_out), max_steps])
1529
+ raw = w3.eth.call({"to": Web3.to_checksum_address(YAK_ROUTER), "data": data})
1530
+ return _decode_formatted_offer(bytes(raw))
1531
+
1532
+ # ─── ParaSwap / Velora route ─────────────────────────────────────────────────
1533
+ # The Prime Account already holds the funds, so the facet (not the EOA) approves the
1534
+ # Augustus router and executes. We only build the API calldata with the Prime Account as
1535
+ # the swapper + receiver, then hand its (selector, data) to paraSwapV6 / swapDebtParaSwap.
1536
+
1537
+ def _paraswap_price_route(src_token, src_dec, dest_token, dest_dec, amount_in_wei, user_addr):
1538
+ """ParaSwap /prices: returns the priceRoute dict for a SELL of amount_in_wei src->dest
1539
+ on Avalanche v6.2. The priceRoute is passed verbatim to /transactions."""
1540
+ params = {
1541
+ "srcToken": src_token, "srcDecimals": src_dec,
1542
+ "destToken": dest_token, "destDecimals": dest_dec,
1543
+ "amount": str(amount_in_wei), "side": "SELL",
1544
+ "network": CHAIN_ID, "version": "6.2", "userAddress": user_addr,
1545
+ }
1546
+ r = requests.get(f"{PARASWAP_API}/prices", params=params,
1547
+ headers={"Accept": "application/json"}, timeout=20)
1548
+ d = r.json()
1549
+ pr = d.get("priceRoute")
1550
+ if not pr:
1551
+ raise RuntimeError(f"ParaSwap /prices returned no route: {d.get('error', d)}")
1552
+ return pr
1553
+
1554
+ def _paraswap_build_tx(price_route, src_token, src_dec, dest_token, dest_dec,
1555
+ amount_in_wei, slippage_pct, user_addr):
1556
+ """ParaSwap /transactions: build the Augustus calldata for the given price route, with
1557
+ the Prime Account as userAddress + receiver. partner='paraswap' makes the encoded
1558
+ partnerAndFee resolve to partner=0/fee=0, which the facet requires (any other partner
1559
+ string injects a non-zero fee/partner the facet would reject). Returns the tx dict."""
1560
+ body = {
1561
+ "srcToken": src_token, "srcDecimals": src_dec,
1562
+ "destToken": dest_token, "destDecimals": dest_dec,
1563
+ "srcAmount": str(amount_in_wei),
1564
+ "slippage": int(round(slippage_pct * 100)), # ParaSwap takes slippage in bps
1565
+ "priceRoute": price_route,
1566
+ "userAddress": user_addr,
1567
+ "receiver": user_addr,
1568
+ "partner": "paraswap",
1569
+ }
1570
+ # ignoreChecks: the swapper is the Prime Account (a contract that holds no funds at
1571
+ # build time and hasn't approved Augustus yet — the facet does that mid-tx), so the
1572
+ # API's balance/allowance pre-checks would reject an otherwise valid build.
1573
+ r = requests.post(f"{PARASWAP_API}/transactions/{CHAIN_ID}?ignoreChecks=true&ignoreGasEstimate=true",
1574
+ json=body, headers={"Accept": "application/json"}, timeout=20)
1575
+ d = r.json()
1576
+ if "data" not in d:
1577
+ raise RuntimeError(f"ParaSwap /transactions returned no calldata: {d.get('error', d)}")
1578
+ return d
1579
+
1580
+ def _paraswap_decode_and_check(selector_hex, data_bytes, src_token, dest_token, expected_from, pa_cs):
1581
+ """Mirror the facet's decodeParaSwapData + validateSwapParameters on the built calldata,
1582
+ so a preview fails loud here rather than reverting on-chain. Returns the decoded
1583
+ (executor, src, dest, fromAmount, toAmount) for display. Only swapExactAmountIn is
1584
+ fully field-decoded; the UniV3 variant is sanity-checked on selector + length only."""
1585
+ if selector_hex not in PARASWAP_SUPPORTED_SELECTORS:
1586
+ raise RuntimeError(f"ParaSwap returned method {selector_hex}, which the facet does not "
1587
+ f"decode (supported: {', '.join(sorted(PARASWAP_SUPPORTED_SELECTORS))}). "
1588
+ "Refusing.")
1589
+ if len(data_bytes) < 288:
1590
+ raise RuntimeError(f"ParaSwap calldata body too short ({len(data_bytes)} bytes, need >=288).")
1591
+
1592
+ if selector_hex == "0xe3ead59e": # swapExactAmountIn — executor ++ GenericData ++ partnerAndFee
1593
+ executor = "0x" + data_bytes[:32][-20:].hex()
1594
+ src, dest, from_amt, to_amt, _quoted, _meta, beneficiary = abi_decode(
1595
+ ["address", "address", "uint256", "uint256", "uint256", "bytes32", "address"],
1596
+ data_bytes[32:256])
1597
+ partner_and_fee = int.from_bytes(data_bytes[256:288], "big")
1598
+ partner = (partner_and_fee >> 96) & ((1 << 160) - 1)
1599
+ fee_bps = partner_and_fee & 0x3FFF
1600
+ if executor.lower() not in PARASWAP_EXECUTORS:
1601
+ print(f" ⚠ ParaSwap executor {executor} not in the KNOWN whitelist — the on-chain facet")
1602
+ print(f" may reject it with InvalidExecutor(). Proceeding anyway; verify on-chain.")
1603
+ if partner != 0 or fee_bps != 0:
1604
+ raise RuntimeError(f"ParaSwap calldata carries a non-zero partner/fee "
1605
+ f"(partner={hex(partner)}, feeBps={fee_bps}); the facet would revert. Refusing.")
1606
+ if Web3.to_checksum_address(src) != Web3.to_checksum_address(src_token) or \
1607
+ Web3.to_checksum_address(dest) != Web3.to_checksum_address(dest_token):
1608
+ raise RuntimeError("ParaSwap calldata src/dest token mismatch vs request. Refusing.")
1609
+ zero = "0x" + "00" * 20
1610
+ if Web3.to_checksum_address(beneficiary) not in (Web3.to_checksum_address(zero), pa_cs):
1611
+ raise RuntimeError(f"ParaSwap beneficiary {Web3.to_checksum_address(beneficiary)} "
1612
+ f"is neither zero nor the Prime Account. Refusing.")
1613
+ if from_amt != expected_from:
1614
+ raise RuntimeError(f"ParaSwap fromAmount {from_amt} != expected {expected_from}. Refusing.")
1615
+ return executor, src, dest, from_amt, to_amt
1616
+ return None, src_token, dest_token, expected_from, None
1617
+
1618
+ def _swap_via_paraswap(w3, acct, pa_cs, account, from_sym, to_sym, from_cfg, to_cfg,
1619
+ amount, amount_in, slippage_pct, execute):
1620
+ """ParaSwap leg of cmd_swap. Builds the Velora calldata for an in-account
1621
+ from_sym->to_sym swap and (on --execute) calls paraSwapV6 with a RedStone payload."""
1622
+ price_route = _paraswap_price_route(from_cfg["token"], from_cfg["decimals"],
1623
+ to_cfg["token"], to_cfg["decimals"], amount_in, pa_cs)
1624
+ quoted_out = int(price_route["destAmount"])
1625
+ tx_built = _paraswap_build_tx(price_route, from_cfg["token"], from_cfg["decimals"],
1626
+ to_cfg["token"], to_cfg["decimals"], amount_in,
1627
+ slippage_pct, pa_cs)
1628
+ full = bytes.fromhex(tx_built["data"][2:])
1629
+ selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
1630
+ _exec, _src, _dest, from_amt, min_out = _paraswap_decode_and_check(
1631
+ selector_hex, data_bytes, from_cfg["token"], to_cfg["token"], amount_in, pa_cs)
1632
+ # Same executor-patching as swap-debt (see cmd_swap_debt for full rationale).
1633
+ _PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
1634
+ if _exec is not None and _exec.lower() not in PARASWAP_EXECUTORS:
1635
+ fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
1636
+ data_bytes = fallback_bytes + data_bytes[32:]
1637
+ print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
1638
+ _paraswap_decode_and_check(selector_hex, data_bytes, from_cfg["token"], to_cfg["token"],
1639
+ amount_in, pa_cs)
1640
+
1641
+ print(f"Swap {amount} {from_sym} -> {to_sym} on Prime Account {pa_cs} (via ParaSwap/Velora)")
1642
+ print(f" Router method: {price_route['contractMethod']} ({selector_hex})")
1643
+ print(f" Augustus router: {tx_built['to']}")
1644
+ print(f" Expected out: {quoted_out / 10**to_cfg['decimals']:.6f} {to_sym}")
1645
+ if min_out is not None:
1646
+ print(f" Min out (@{slippage_pct}% slippage): {min_out / 10**to_cfg['decimals']:.6f} {to_sym}")
1647
+ print(f" ParaSwap srcUSD ${price_route.get('srcUSD','?')} -> destUSD ${price_route.get('destUSD','?')}")
1648
+ print(" Facet enforces a 5% hard slippage cap (RedStone-priced) on top of this.")
1649
+
1650
+ if not execute:
1651
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
1652
+ return
1653
+
1654
+ feeds = prime_account_price_feeds(account)
1655
+ for s in (from_sym, to_sym):
1656
+ if s not in feeds:
1657
+ feeds.append(s)
1658
+ payload = build_redstone_payload(feeds)
1659
+ base_calldata = account.encode_abi("paraSwapV6", args=[full[:4], data_bytes])
1660
+ data = base_calldata + payload.hex()
1661
+ tx = {
1662
+ "from": acct.address, "to": pa_cs, "data": data,
1663
+ "nonce": w3.eth.get_transaction_count(acct.address),
1664
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1665
+ }
1666
+ signed = acct.sign_transaction(tx)
1667
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1668
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1669
+ ok = receipt["status"] == 1
1670
+ print(f"{'✓' if ok else '✗'} Swap {amount} {from_sym} -> {to_sym} "
1671
+ f"{'confirmed' if ok else 'failed'}")
1672
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1673
+ return ok
1674
+
1675
+ def cmd_swap(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.0,
1676
+ via: str = "yak", execute: bool = False):
1677
+ """Swap one in-account asset for another via the Prime Account, on either aggregator.
1678
+
1679
+ The swap sells the account's in-account balance of --from for --to.
1680
+ via='yak' — YieldYakSwapFacet.yakSwap; route (path+adapters) from the YieldYak
1681
+ router's findBestPath, each adapter whitelisted on the facet.
1682
+ via='paraswap' — ParaSwapFacet.paraSwapV6; calldata built from the ParaSwap/Velora
1683
+ v6.2 API (/prices then /transactions) with the Prime Account as
1684
+ swapper+receiver, split into (selector, data) for the facet.
1685
+ Both carry remainsSolvent, so the --execute path appends a RedStone signed-price
1686
+ payload to the calldata.
1687
+ """
1688
+ via = (via or "yak").lower()
1689
+ if via not in ("yak", "paraswap"):
1690
+ print(f"Unknown --via '{via}'. Choose 'yak' or 'paraswap'.")
1691
+ return
1692
+ from_sym, to_sym = from_sym.upper(), to_sym.upper()
1693
+ if from_sym not in SWAP_ASSETS:
1694
+ print(f"Unknown --from asset '{from_sym}'. Choose from: {', '.join(SWAP_ASSETS)}")
1695
+ return
1696
+ if to_sym not in SWAP_ASSETS:
1697
+ print(f"Unknown --to asset '{to_sym}'. Choose from: {', '.join(SWAP_ASSETS)}")
1698
+ return
1699
+ if from_sym == to_sym:
1700
+ print("--from and --to must differ.")
1701
+ return
1702
+
1703
+ w3 = get_w3()
1704
+ acct = get_account()
1705
+ pa = get_prime_account(w3, acct.address)
1706
+ if not pa:
1707
+ print("No Prime Account exists for this wallet — nothing to swap.")
1708
+ print("Create and fund one first: deltaprime create-prime-account --execute")
1709
+ return
1710
+ pa_cs = Web3.to_checksum_address(pa)
1711
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
1712
+
1713
+ from_cfg, to_cfg = SWAP_ASSETS[from_sym], SWAP_ASSETS[to_sym]
1714
+ amount_in = int(amount * 10**from_cfg["decimals"])
1715
+
1716
+ # In-account balance check (oracle-free view).
1717
+ in_balance = account.functions.getBalance(asset_b32(from_sym)).call()
1718
+ if amount_in > in_balance:
1719
+ print(f"Prime Account holds only {in_balance / 10**from_cfg['decimals']:.6f} {from_sym} "
1720
+ f"in-account; cannot swap {amount} {from_sym}.")
1721
+ print("Fund or borrow more of the asset into the account first.")
1722
+ return
1723
+
1724
+ if via == "paraswap":
1725
+ return _swap_via_paraswap(w3, acct, pa_cs, account, from_sym, to_sym, from_cfg, to_cfg,
1726
+ amount, amount_in, slippage_pct, execute)
1727
+
1728
+ # Route via YieldYak router.
1729
+ amounts, adapters, path = _yak_find_best_path(w3, amount_in, from_cfg["token"], to_cfg["token"])
1730
+ expected_out = amounts[-1]
1731
+ min_out = int(expected_out * (1 - slippage_pct / 100))
1732
+
1733
+ # Every adapter must be whitelisted on the facet, else yakSwap reverts.
1734
+ not_whitelisted = [a for a in adapters
1735
+ if not account.functions.isWhitelistedAdapterOptimized(
1736
+ Web3.to_checksum_address(a)).call()]
1737
+
1738
+ print(f"Swap {amount} {from_sym} -> {to_sym} on Prime Account {pa}")
1739
+ print(f" Route ({len(adapters)} hop{'s' if len(adapters) != 1 else ''}): "
1740
+ f"{' -> '.join(path)}")
1741
+ print(f" Adapters: {', '.join(adapters)}")
1742
+ print(f" Expected out: {expected_out / 10**to_cfg['decimals']:.6f} {to_sym}")
1743
+ print(f" Min out (@{slippage_pct}% slippage): {min_out / 10**to_cfg['decimals']:.6f} {to_sym}")
1744
+ if not_whitelisted:
1745
+ print(f" ✗ Non-whitelisted adapter(s) in route: {', '.join(not_whitelisted)}")
1746
+ print(" yakSwap would revert. Refusing.")
1747
+ return
1748
+ print(" All adapters whitelisted.")
1749
+
1750
+ if not execute:
1751
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
1752
+ return
1753
+
1754
+ # remainsSolvent gating: append a RedStone signed-price payload covering the
1755
+ # account's assets + debts plus the swap's from/to. Fetched fresh at send time —
1756
+ # the payload is only valid for ~3 minutes (RedStone staleness window).
1757
+ feeds = prime_account_price_feeds(account)
1758
+ for s in (from_sym, to_sym):
1759
+ if s not in feeds:
1760
+ feeds.append(s)
1761
+ payload = build_redstone_payload(feeds)
1762
+
1763
+ base_calldata = account.encode_abi("yakSwap", args=[
1764
+ amount_in, min_out,
1765
+ [Web3.to_checksum_address(p) for p in path],
1766
+ [Web3.to_checksum_address(a) for a in adapters],
1767
+ ])
1768
+ data = base_calldata + payload.hex()
1769
+ tx = {
1770
+ "from": acct.address, "to": pa_cs, "data": data,
1771
+ "nonce": w3.eth.get_transaction_count(acct.address),
1772
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1773
+ }
1774
+ signed = acct.sign_transaction(tx)
1775
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1776
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1777
+ ok = receipt["status"] == 1
1778
+ print(f"{'✓' if ok else '✗'} Swap {amount} {from_sym} -> {to_sym} "
1779
+ f"{'confirmed' if ok else 'failed'}")
1780
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1781
+ return ok
1782
+
1783
+ # ─── Swap debt / refinance (SwapDebtFacet) ───────────────────────────────────
1784
+ # swapDebtParaSwap borrows _borrowAmount of _toAsset, ParaSwaps it into _fromAsset, and
1785
+ # repays _repayAmount of the _fromAsset debt — all in one tx. The facet hard-caps the USD
1786
+ # value difference between the repay and borrow legs at 5% (RedStone-priced) and requires
1787
+ # the ParaSwap quote's fromAmount to equal _borrowAmount exactly.
1788
+
1789
+ _SYMBOL_TO_POOL = {cfg["symbol"]: name for name, cfg in POOLS.items()}
1790
+
1791
+ def _read_prices_usd(w3, account, symbols, payload):
1792
+ """RedStone-gated getPrices read for `symbols` (1e8-scaled USD), payload appended."""
1793
+ data = account.encode_abi("getPrices", args=[[asset_b32(s) for s in symbols]]) + payload.hex()
1794
+ raw = w3.eth.call({"to": account.address, "data": data})
1795
+ return w3.codec.decode(["uint256[]"], bytes(raw))[0]
1796
+
1797
+ def cmd_swap_debt(from_sym: str, to_sym: str, amount: float, slippage_pct: float = 1.0,
1798
+ execute: bool = False):
1799
+ """Refinance debt from --from (existing debt) into --to (new debt) via
1800
+ SwapDebtFacet.swapDebtParaSwap. --amount is how much of the OLD (--from) debt to repay,
1801
+ in --from units. We value-match the new borrow to the repay using the facet's own
1802
+ RedStone prices, build the ParaSwap calldata for the internal --to -> --from swap, and
1803
+ preview the 5% USD-diff cap. RedStone-gated on execute."""
1804
+ from_sym, to_sym = from_sym.upper(), to_sym.upper()
1805
+ if from_sym not in SWAP_ASSETS:
1806
+ print(f"Unknown --from (old debt) asset '{from_sym}'. Choose from: {', '.join(SWAP_ASSETS)}")
1807
+ return
1808
+ if to_sym not in SWAP_ASSETS:
1809
+ print(f"Unknown --to (new debt) asset '{to_sym}'. Choose from: {', '.join(SWAP_ASSETS)}")
1810
+ return
1811
+ if from_sym == to_sym:
1812
+ print("--from and --to must differ.")
1813
+ return
1814
+
1815
+ w3 = get_w3()
1816
+ acct = get_account()
1817
+ pa = get_prime_account(w3, acct.address)
1818
+ if not pa:
1819
+ print("No Prime Account exists for this wallet — no debt to swap.")
1820
+ print("Swap-debt only applies to a Prime Account with an outstanding loan.")
1821
+ return
1822
+ pa_cs = Web3.to_checksum_address(pa)
1823
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
1824
+
1825
+ from_cfg, to_cfg = SWAP_ASSETS[from_sym], SWAP_ASSETS[to_sym]
1826
+
1827
+ # Current borrowed of the OLD debt asset, read from its pool (the facet caps
1828
+ # _repayAmount to exactly this).
1829
+ from_pool, _, _ = get_pool_contract(_SYMBOL_TO_POOL[from_sym])
1830
+ borrowed = from_pool.functions.getBorrowed(pa_cs).call()
1831
+ if borrowed == 0:
1832
+ print(f"Prime Account has no {from_sym} debt to refinance.")
1833
+ return
1834
+ repay_amount = min(int(amount * 10**from_cfg["decimals"]), borrowed)
1835
+
1836
+ # Value-match the new borrow to the repay using the facet's own RedStone prices, so the
1837
+ # 5% USD-diff cap is computed against the same numbers the contract will see.
1838
+ feeds = prime_account_price_feeds(account)
1839
+ for s in (from_sym, to_sym):
1840
+ if s not in feeds:
1841
+ feeds.append(s)
1842
+ payload = build_redstone_payload(feeds)
1843
+ price_from, price_to = _read_prices_usd(w3, account, [from_sym, to_sym], payload)
1844
+ # borrow_amount such that its USD value ≈ repay USD value:
1845
+ # repay_usd = price_from * repay_amount / 10**from_dec
1846
+ # borrow_amt = repay_usd * 10**to_dec / price_to
1847
+ borrow_amount = (price_from * repay_amount * 10**to_cfg["decimals"]) // (price_to * 10**from_cfg["decimals"])
1848
+ if borrow_amount == 0:
1849
+ print("Computed borrow amount rounds to zero — repay amount too small. Refusing.")
1850
+ return
1851
+
1852
+ # USD values + diff (mirror of the facet's maxDiff math, prices 1e8-scaled).
1853
+ repay_usd = price_from * repay_amount / 10**from_cfg["decimals"] / 1e8
1854
+ borrow_usd = price_to * borrow_amount / 10**to_cfg["decimals"] / 1e8
1855
+ diff_bps = (abs(repay_usd - borrow_usd) / max(repay_usd, borrow_usd)) * 10000 if max(repay_usd, borrow_usd) else 0
1856
+
1857
+ # ParaSwap calldata for the INTERNAL swap: sell exactly borrow_amount of _toAsset for
1858
+ # _fromAsset (facet requires fromAmount == _borrowAmount). srcToken=to, destToken=from.
1859
+ price_route = _paraswap_price_route(to_cfg["token"], to_cfg["decimals"],
1860
+ from_cfg["token"], from_cfg["decimals"], borrow_amount, pa_cs)
1861
+ quoted_out = int(price_route["destAmount"])
1862
+ tx_built = _paraswap_build_tx(price_route, to_cfg["token"], to_cfg["decimals"],
1863
+ from_cfg["token"], from_cfg["decimals"], borrow_amount,
1864
+ slippage_pct, pa_cs)
1865
+ full = bytes.fromhex(tx_built["data"][2:])
1866
+ selector_hex, data_bytes = "0x" + full[:4].hex(), full[4:]
1867
+ _exec, _src, _dest, swap_from_amt, swap_min_out = _paraswap_decode_and_check(
1868
+ selector_hex, data_bytes, to_cfg["token"], from_cfg["token"], borrow_amount, pa_cs)
1869
+
1870
+ # If the ParaSwap API returned a new executor not on the DeltaPrime whitelist, patch
1871
+ # it to EXECUTOR_3 (0x00001003…A07380A) — the only legacy executor whose calldata
1872
+ # format is compatible with the current API's output (tested on-chain 2026-05-28).
1873
+ _PARASWAP_FALLBACK_EXECUTOR = "0x000010036C0190E009a000d0fc3541100A07380A"
1874
+ if _exec.lower() not in PARASWAP_EXECUTORS:
1875
+ fallback_bytes = bytes(12) + bytes.fromhex(_PARASWAP_FALLBACK_EXECUTOR[2:])
1876
+ data_bytes = fallback_bytes + data_bytes[32:]
1877
+ print(f" ⚠ Executor {_exec} not whitelisted; patching to {_PARASWAP_FALLBACK_EXECUTOR}")
1878
+ _paraswap_decode_and_check(selector_hex, data_bytes, to_cfg["token"], from_cfg["token"],
1879
+ borrow_amount, pa_cs)
1880
+
1881
+ print(f"Swap debt on Prime Account {pa}")
1882
+ print(f" Refinance: {from_sym} debt -> {to_sym} debt")
1883
+ print(f" Old debt ({from_sym}): {borrowed / 10**from_cfg['decimals']:.6f} total; "
1884
+ f"repaying {repay_amount / 10**from_cfg['decimals']:.6f}")
1885
+ print(f" New debt ({to_sym}): borrow {borrow_amount / 10**to_cfg['decimals']:.6f}")
1886
+ print(f" Internal swap (ParaSwap): {borrow_amount / 10**to_cfg['decimals']:.6f} {to_sym} "
1887
+ f"-> {from_sym} ({price_route['contractMethod']} {selector_hex})")
1888
+ print(f" Expected {from_sym} out: {quoted_out / 10**from_cfg['decimals']:.6f}", end="")
1889
+ if swap_min_out is not None:
1890
+ print(f" (min {swap_min_out / 10**from_cfg['decimals']:.6f} @{slippage_pct}% slippage)")
1891
+ else:
1892
+ print()
1893
+ print(f" RedStone USD: repay ${repay_usd:,.4f} vs borrow ${borrow_usd:,.4f} "
1894
+ f"-> diff {diff_bps:.1f} bps (facet cap: 500 bps / 5%)")
1895
+ if diff_bps > 500:
1896
+ print(" ✗ USD-value diff exceeds the facet's 5% cap. swapDebtParaSwap would revert. Refusing.")
1897
+ return
1898
+ if quoted_out < repay_amount:
1899
+ print(f" Note: quoted {from_sym} out is below the repay target; the facet repays "
1900
+ f"min(swap output, {repay_amount / 10**from_cfg['decimals']:.6f}, debt) — any shortfall "
1901
+ "leaves residual old debt.")
1902
+
1903
+ if not execute:
1904
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
1905
+ return
1906
+
1907
+ base_calldata = account.encode_abi("swapDebtParaSwap", args=[
1908
+ asset_b32(from_sym), asset_b32(to_sym), repay_amount, borrow_amount,
1909
+ full[:4], data_bytes,
1910
+ ])
1911
+ data = base_calldata + payload.hex()
1912
+ tx = {
1913
+ "from": acct.address, "to": pa_cs, "data": data,
1914
+ "nonce": w3.eth.get_transaction_count(acct.address),
1915
+ "gas": 4000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1916
+ }
1917
+ signed = acct.sign_transaction(tx)
1918
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1919
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1920
+ ok = receipt["status"] == 1
1921
+ print(f"{'✓' if ok else '✗'} Swap debt {from_sym} -> {to_sym} {'confirmed' if ok else 'failed'}")
1922
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1923
+
1924
+ # ─── Collateral withdrawal (WithdrawalIntentFacet) ──────────────────────────
1925
+ # Pulling collateral out of the Prime Account to the EOA is a two-step, time-delayed
1926
+ # flow — there is NO instant withdraw. createWithdrawalIntent registers an intent,
1927
+ # then executeWithdrawalIntent pulls it after maturity. Window (from source, also
1928
+ # exposed on-chain via the IntentInfo flags): actionableAt = createdAt + 24h,
1929
+ # expiresAt = actionableAt + 48h. So an intent is executable in a 24h–72h window.
1930
+
1931
+ def _fmt_window(actionable_at: int, expires_at: int) -> str:
1932
+ """Human one-liner for an intent's maturity window, anchored to chain time."""
1933
+ now = int(time.time())
1934
+ def rel(ts):
1935
+ d = ts - now
1936
+ sign = "in" if d >= 0 else "ago"
1937
+ d = abs(d)
1938
+ h, m = d // 3600, (d % 3600) // 60
1939
+ span = f"{h}h{m:02d}m" if h else f"{m}m"
1940
+ return f"{sign} {span}" if sign == "in" else f"{span} {sign}"
1941
+ a = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(actionable_at))
1942
+ e = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(expires_at))
1943
+ return f"actionable {a} ({rel(actionable_at)}), expires {e} ({rel(expires_at)})"
1944
+
1945
+ def cmd_withdraw_collateral(pool_name: str, amount: float, execute: bool = False):
1946
+ """Step 1 of collateral withdrawal: register a WithdrawalIntent on the Prime
1947
+ Account via createWithdrawalIntent(bytes32 asset, uint256 amount). Per the
1948
+ capabilities doc this does NOT need a RedStone payload (the solvency check is
1949
+ deferred to the execute step). After ~24h the intent becomes executable for a 48h
1950
+ window (see execute-withdrawal). Preview by default."""
1951
+ cfg = POOLS[pool_name]
1952
+ w3 = get_w3()
1953
+ acct = get_account()
1954
+ pa = get_prime_account(w3, acct.address)
1955
+ if not pa:
1956
+ print("No Prime Account exists for this wallet — nothing to withdraw.")
1957
+ print("Collateral withdrawal only applies to a Prime Account.")
1958
+ return
1959
+
1960
+ symbol = pool_to_asset_symbol(pool_name)
1961
+ amount_wei = int(amount * 10**cfg["decimals"])
1962
+ pa_cs = Web3.to_checksum_address(pa)
1963
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
1964
+
1965
+ # getAvailableBalance is oracle-free: in-account balance minus staked minus pending
1966
+ # intents — the amount still free to register. A useful guard for the preview.
1967
+ available = account.functions.getAvailableBalance(asset_b32(symbol)).call()
1968
+ print(f"Create withdrawal intent: {amount} {symbol} from Prime Account {pa}")
1969
+ print(f" Available to withdraw now: {available / 10**cfg['decimals']:.6f} {symbol}")
1970
+ if amount_wei > available:
1971
+ print(f" ✗ Requested {amount} {symbol} exceeds available balance. Refusing.")
1972
+ return
1973
+ print(f" Calls createWithdrawalIntent(bytes32 '{symbol}', {amount_wei}) — no RedStone payload needed.")
1974
+ print(" Delayed flow: becomes executable ~24h later, then has a 48h window (24h-72h total).")
1975
+ print(" Run `execute-withdrawal --pool <p>` after maturity to pull the funds to the wallet.")
1976
+
1977
+ if not execute:
1978
+ print("Run with --execute to broadcast (registers the intent on-chain).")
1979
+ return
1980
+
1981
+ tx = account.functions.createWithdrawalIntent(asset_b32(symbol), amount_wei).build_transaction({
1982
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
1983
+ "gas": 1000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
1984
+ })
1985
+ signed = acct.sign_transaction(tx)
1986
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
1987
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
1988
+ ok = receipt["status"] == 1
1989
+ print(f"{'✓' if ok else '✗'} Withdrawal intent {'registered' if ok else 'failed'}")
1990
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
1991
+
1992
+ def cmd_withdrawal_intents():
1993
+ """Read-only: list pending withdrawal intents per owned asset, with per-asset
1994
+ available balance. Uses the oracle-free WithdrawalIntentFacet views getUserIntents /
1995
+ getAvailableBalance / getTotalIntentAmount — no RedStone, no tx."""
1996
+ w3 = get_w3()
1997
+ acct = get_account()
1998
+ pa = get_prime_account(w3, acct.address)
1999
+ print(f"Owner wallet: {acct.address}")
2000
+ if not pa:
2001
+ print("No Prime Account yet — no withdrawal intents.")
2002
+ return
2003
+
2004
+ print(f"Prime Account: {pa}")
2005
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
2006
+ owned = account.functions.getAllOwnedAssets().call()
2007
+ if not owned:
2008
+ print(" Account holds no assets — nothing to withdraw.")
2009
+ return
2010
+
2011
+ any_pending = False
2012
+ for a in owned:
2013
+ sym = a.rstrip(b"\x00").decode(errors="replace")
2014
+ dec = _asset_decimals(sym)
2015
+ available = account.functions.getAvailableBalance(a).call()
2016
+ total_intent = account.functions.getTotalIntentAmount(a).call()
2017
+ intents = account.functions.getUserIntents(a).call()
2018
+ print(f" {sym}: available {available / 10**dec:,.6f}, "
2019
+ f"pending intents {total_intent / 10**dec:,.6f}")
2020
+ for idx, (amt, actionable_at, expires_at, is_pending, is_actionable, is_expired) in enumerate(intents):
2021
+ any_pending = True
2022
+ if is_expired:
2023
+ state = "EXPIRED"
2024
+ elif is_actionable:
2025
+ state = "READY to execute"
2026
+ elif is_pending:
2027
+ state = "maturing"
2028
+ else:
2029
+ state = "inactive"
2030
+ print(f" [{idx}] {amt / 10**dec:,.6f} {sym} — {state}")
2031
+ print(f" {_fmt_window(actionable_at, expires_at)}")
2032
+ if not any_pending:
2033
+ print(" No pending withdrawal intents.")
2034
+
2035
+ def cmd_execute_withdrawal(pool_name: str, index: int = None, execute: bool = False):
2036
+ """Step 2 of collateral withdrawal: executeWithdrawalIntent(bytes32 asset,
2037
+ uint256[] indices) pulls matured intent(s) to the EOA. This DOES carry remainsSolvent
2038
+ (+ canRepayDebtFully), so --execute appends a fresh RedStone price payload. Refuses
2039
+ any intent that has not matured (isActionable=false) or has expired. --index selects
2040
+ one intent; default executes all currently-actionable intents for the asset (indices
2041
+ passed strictly increasing, as the contract requires)."""
2042
+ cfg = POOLS[pool_name]
2043
+ w3 = get_w3()
2044
+ acct = get_account()
2045
+ pa = get_prime_account(w3, acct.address)
2046
+ if not pa:
2047
+ print("No Prime Account exists for this wallet — nothing to execute.")
2048
+ return
2049
+
2050
+ symbol = pool_to_asset_symbol(pool_name)
2051
+ pa_cs = Web3.to_checksum_address(pa)
2052
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2053
+ intents = account.functions.getUserIntents(asset_b32(symbol)).call()
2054
+ if not intents:
2055
+ print(f"No withdrawal intents registered for {symbol}.")
2056
+ print("Register one first: withdraw-collateral --pool <p> --amount <n> --execute")
2057
+ return
2058
+
2059
+ # Pick indices: a specific --index, or every currently-actionable intent.
2060
+ if index is not None:
2061
+ if index < 0 or index >= len(intents):
2062
+ print(f"--index {index} out of range (asset has {len(intents)} intent(s)).")
2063
+ return
2064
+ candidates = [index]
2065
+ else:
2066
+ candidates = [i for i, it in enumerate(intents) if it[4]] # isActionable
2067
+
2068
+ print(f"Execute withdrawal of {symbol} from Prime Account {pa}")
2069
+ ready = []
2070
+ for i in candidates:
2071
+ amt, actionable_at, expires_at, is_pending, is_actionable, is_expired = intents[i]
2072
+ print(f" [{i}] {amt / 10**cfg['decimals']:,.6f} {symbol} — "
2073
+ f"{'EXPIRED' if is_expired else 'READY' if is_actionable else 'NOT MATURED'}")
2074
+ print(f" {_fmt_window(actionable_at, expires_at)}")
2075
+ if is_expired:
2076
+ print(f" ✗ intent [{i}] has expired — cannot execute (cancel/clear it instead).")
2077
+ elif not is_actionable:
2078
+ print(f" ✗ intent [{i}] has not matured yet — refusing.")
2079
+ else:
2080
+ ready.append(i)
2081
+
2082
+ if not ready:
2083
+ print(" No matured, non-expired intents to execute. Refusing.")
2084
+ return
2085
+ ready.sort() # contract requires strictly-increasing indices
2086
+ print(f" Will execute indices {ready} via executeWithdrawalIntent(bytes32 '{symbol}', {ready}).")
2087
+ print(" Carries remainsSolvent + canRepayDebtFully — appends a fresh RedStone payload.")
2088
+
2089
+ if not execute:
2090
+ print("Run with --execute to broadcast (pulls the funds to the wallet).")
2091
+ return
2092
+
2093
+ feeds = prime_account_price_feeds(account)
2094
+ if symbol not in feeds:
2095
+ feeds.append(symbol)
2096
+ payload = build_redstone_payload(feeds)
2097
+ base_calldata = account.encode_abi("executeWithdrawalIntent", args=[asset_b32(symbol), ready])
2098
+ data = base_calldata + payload.hex()
2099
+ tx = {
2100
+ "from": acct.address, "to": pa_cs, "data": data,
2101
+ "nonce": w3.eth.get_transaction_count(acct.address),
2102
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
2103
+ }
2104
+ signed = acct.sign_transaction(tx)
2105
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2106
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
2107
+ ok = receipt["status"] == 1
2108
+ print(f"{'✓' if ok else '✗'} Execute withdrawal {'confirmed' if ok else 'failed'}")
2109
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2110
+
2111
+ # ─── GMX V2 GM / GM+ LP (GmxV2FacetAvalanche / GmxV2PlusFacetAvalanche) ───────
2112
+ # GM tokens are GMX V2 market LP (two-sided long+short for GM, single-sided for GM+).
2113
+ # The deposit/withdraw flow is PAYABLE + ASYNC: the facet forwards a GMX execution fee as
2114
+ # msg.value (== the executionFee arg) to the GMX ExchangeRouter, which queues the request;
2115
+ # a GMX keeper executes it some blocks later and fires a callback. The Prime Account is
2116
+ # FROZEN for that market until the callback resolves — the position does NOT appear (or
2117
+ # disappear) instantly. minGmAmount / min long+short token amounts are slippage floors,
2118
+ # hard-bounded to ±5% of the oracle estimate by the facet's isWithinBounds check.
2119
+
2120
+ def _gmx_datastore_key(name: str) -> bytes:
2121
+ """GMX DataStore key = keccak256(abi.encode(string name)). The gas-limit params live
2122
+ under these string keys (see gmx-synthetics Keys.sol)."""
2123
+ return Web3.keccak(abi_encode(["string"], [name]))
2124
+
2125
+ def _estimate_gmx_execution_fee(w3, is_deposit: bool, buffer_mult: float = 2.0):
2126
+ """Estimate the GMX V2 execution fee (in wei of AVAX) the keeper will require, mirroring
2127
+ gmx-synthetics GasUtils. The keeper executes only when executionFee >= adjustedGasLimit *
2128
+ tx.gasprice AT EXECUTION TIME, so we pad the current gas price by `buffer_mult` to survive
2129
+ a gas-price rise between submission and keeper execution. Any excess is refunded by GMX to
2130
+ the receiver (the Prime Account). Returns (fee_wei, detail dict)."""
2131
+ ds = w3.eth.contract(address=Web3.to_checksum_address(GMX_DATASTORE), abi=GMX_DATASTORE_ABI)
2132
+ base_gas = ds.functions.getUint(_gmx_datastore_key(
2133
+ "DEPOSIT_GAS_LIMIT" if is_deposit else "WITHDRAWAL_GAS_LIMIT")).call()
2134
+ base_amount = ds.functions.getUint(_gmx_datastore_key("ESTIMATED_GAS_FEE_BASE_AMOUNT_V2_1")).call()
2135
+ per_oracle = ds.functions.getUint(_gmx_datastore_key("ESTIMATED_GAS_FEE_PER_ORACLE_PRICE")).call()
2136
+ mult_factor = ds.functions.getUint(_gmx_datastore_key("ESTIMATED_GAS_FEE_MULTIPLIER_FACTOR")).call()
2137
+ # estimateExecute{Deposit,Withdrawal}GasLimit (no swap path) = baseGasLimit + callbackGasLimit.
2138
+ # adjustGasLimitForEstimate: base + perOracle*oracleCount + applyFactor(estimate, multiplier),
2139
+ # applyFactor(v, f) = v * f / 1e30. Deposit/withdraw with no swaps prices 2 oracle feeds.
2140
+ oracle_count = 2
2141
+ estimate = base_gas + GMX_CALLBACK_GAS_LIMIT
2142
+ adjusted = base_amount + per_oracle * oracle_count + estimate * mult_factor // 10**30
2143
+ # Floor the gas price: Avalanche's live base fee (seen at 0.01-0.02 gwei) is far below the
2144
+ # gas price a GMX keeper uses at execution, so an unfloored estimate yields a fee the keeper
2145
+ # would never accept (the request expires and refunds without minting the GM tokens). 25 gwei
2146
+ # is Avalanche's normal-load price; with buffer_mult this comfortably clears GMX's requirement
2147
+ # (~0.08 AVAX, matching the DeltaPrime app). Any excess is refunded by GMX to the account.
2148
+ gas_price = max(w3.eth.gas_price, 25 * 10**9)
2149
+ fee_wei = int(adjusted * gas_price * buffer_mult)
2150
+ return fee_wei, {"adjusted_gas": adjusted, "gas_price": gas_price, "buffer_mult": buffer_mult}
2151
+
2152
+ def _gmx_underlying_price_usd(w3, account, payload, symbol: str) -> int:
2153
+ """1e8-scaled USD price of a lending underlying (AVAX/BTC/ETH/USDC) via the
2154
+ RedStone-gated SolvencyFacet.getPrices. (The GM token symbol itself has no SolvencyFacet
2155
+ feed — getPrices reverts 0xec459bc0 on it — so GM prices come from the gateway median.)"""
2156
+ return _read_prices_usd(w3, account, [symbol], payload)[0]
2157
+
2158
+ def _gmx_gm_price_usd(gm_feed: str) -> float:
2159
+ """USD price of a GM/GM+ token, taken as the median of the RedStone gateway packages for
2160
+ its feed id. This is the same on-demand value the facet aggregates from the calldata
2161
+ payload, so a minGmAmount computed against it matches what the on-chain isWithinBounds
2162
+ check sees (both read the same gateway snapshot in the same ~3-minute window)."""
2163
+ import statistics
2164
+ gw = _redstone_fetch_packages()
2165
+ vals = []
2166
+ for pkg in gw.get(gm_feed, []):
2167
+ for dp in pkg["dataPoints"]:
2168
+ if dp["dataFeedId"] == gm_feed:
2169
+ vals.append(float(dp["value"]))
2170
+ if not vals:
2171
+ raise RuntimeError(f"RedStone gateway has no GM feed '{gm_feed}'")
2172
+ return statistics.median(vals)
2173
+
2174
+ def _gmx_market_reserve_split(w3, mkt: dict, p_long: int, p_short: int):
2175
+ """Long/short USD weighting of a two-sided GM market, from the underlying token balances
2176
+ held by the market contract. GMX redeems GM pro-rata across this composition, so it's how
2177
+ a withdrawal's min long/short token floors are split. Returns (long_frac, short_frac)."""
2178
+ erc = json.loads('[{"inputs":[{"name":"a","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2179
+ long_cfg, short_cfg = SWAP_ASSETS[mkt["long"]], SWAP_ASSETS[mkt["short"]]
2180
+ gm = Web3.to_checksum_address(mkt["gm_token"])
2181
+ long_tok = w3.eth.contract(address=Web3.to_checksum_address(long_cfg["token"]), abi=erc)
2182
+ short_tok = w3.eth.contract(address=Web3.to_checksum_address(short_cfg["token"]), abi=erc)
2183
+ long_usd = long_tok.functions.balanceOf(gm).call() / 10**long_cfg["decimals"] * p_long / 1e8
2184
+ short_usd = short_tok.functions.balanceOf(gm).call() / 10**short_cfg["decimals"] * p_short / 1e8
2185
+ tot = long_usd + short_usd
2186
+ if tot <= 0:
2187
+ return 0.5, 0.5
2188
+ return long_usd / tot, short_usd / tot
2189
+
2190
+ GMX_READER_ABI = json.loads('''[
2191
+ {"inputs":[{"internalType":"contract DataStore","name":"dataStore","type":"address"},{"internalType":"address","name":"key","type":"address"}],"name":"getMarket","outputs":[{"components":[{"internalType":"address","name":"marketToken","type":"address"},{"internalType":"address","name":"indexToken","type":"address"},{"internalType":"address","name":"longToken","type":"address"},{"internalType":"address","name":"shortToken","type":"address"}],"internalType":"struct Market.Props","name":"","type":"tuple"}],"stateMutability":"view","type":"function"},
2192
+ {"inputs":[{"internalType":"contract DataStore","name":"dataStore","type":"address"},{"components":[{"internalType":"address","name":"marketToken","type":"address"},{"internalType":"address","name":"indexToken","type":"address"},{"internalType":"address","name":"longToken","type":"address"},{"internalType":"address","name":"shortToken","type":"address"}],"internalType":"struct Market.Props","name":"market","type":"tuple"},{"components":[{"components":[{"internalType":"uint256","name":"min","type":"uint256"},{"internalType":"uint256","name":"max","type":"uint256"}],"internalType":"struct Price.Props","name":"indexTokenPrice","type":"tuple"},{"components":[{"internalType":"uint256","name":"min","type":"uint256"},{"internalType":"uint256","name":"max","type":"uint256"}],"internalType":"struct Price.Props","name":"longTokenPrice","type":"tuple"},{"components":[{"internalType":"uint256","name":"min","type":"uint256"},{"internalType":"uint256","name":"max","type":"uint256"}],"internalType":"struct Price.Props","name":"shortTokenPrice","type":"tuple"}],"internalType":"struct MarketUtils.MarketPrices","name":"prices","type":"tuple"},{"internalType":"uint256","name":"marketTokenAmount","type":"uint256"},{"internalType":"address","name":"uiFeeReceiver","type":"address"},{"internalType":"enum ISwapPricingUtils.SwapPricingType","name":"swapPricingType","type":"uint8"}],"name":"getWithdrawalAmountOut","outputs":[{"internalType":"uint256","name":"","type":"uint256"},{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}
2193
+ ]''')
2194
+
2195
+ def _gmx_withdrawal_amount_out(w3, account, mkt: dict, gm_amount: int, p_long: int, p_short: int):
2196
+ """Simulate a withdrawal via the GMX Reader to get the exact expected long/short token
2197
+ output amounts (in the tokens' native decimals). This eliminates the divergence between
2198
+ the reserve-based split estimate and the actual market redemption that causes keeper
2199
+ cancellations with InsufficientOutputAmount."""
2200
+ reader = w3.eth.contract(address=Web3.to_checksum_address(GMX_READER), abi=GMX_READER_ABI)
2201
+ gm_addr = Web3.to_checksum_address(mkt["gm_token"])
2202
+ # Get the on-chain market struct (marketToken, indexToken, longToken, shortToken)
2203
+ market = reader.functions.getMarket(GMX_DATASTORE, gm_addr).call()
2204
+ # Convert RedStone 1e8 USD prices to GMX's internal 1e30 precision
2205
+ PRICE_SCALE = 10 ** 22 # 1e30 / 1e8
2206
+ price_long = int(p_long * PRICE_SCALE)
2207
+ price_short = price_long if mkt["plus"] else int(p_short * PRICE_SCALE)
2208
+ # Price.Props(min, max) — both set to the same oracle median
2209
+ long_pp = (price_long, price_long)
2210
+ short_pp = (price_short, price_short)
2211
+ # MarketPrices(indexTokenPrice, longTokenPrice, shortTokenPrice) — index==long for all GMX markets
2212
+ prices = (long_pp, long_pp, short_pp)
2213
+ long_out, short_out = reader.functions.getWithdrawalAmountOut(
2214
+ GMX_DATASTORE, market, prices, gm_amount, ZERO_ADDRESS, 0
2215
+ ).call()
2216
+ return long_out, short_out
2217
+
2218
+ def gather_gmx(w3, account):
2219
+ """Read-only GM / GM+ LP positions on a Prime Account. Per owned market: raw GM balance,
2220
+ balance after the accrued performance fee (getGmTokenBalanceAfterFees), annualised
2221
+ performance (getGm[Plus]Performance), and best-effort USD via the RedStone gateway GM
2222
+ price. Both views are RedStone-gated — a fresh signed payload (GM feed + underlyings) is
2223
+ appended per market. Returns a list of position dicts (empty if none). Shared by
2224
+ cmd_gmx_positions (print) and cmd_defi (--json)."""
2225
+ pa_cs = account.address
2226
+ owned = {a.rstrip(b"\x00").decode(errors="replace") for a in account.functions.getAllOwnedAssets().call()}
2227
+ erc = json.loads('[{"inputs":[{"name":"a","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2228
+ positions = []
2229
+ for key, mkt in GMX_MARKETS.items():
2230
+ gm_cs = Web3.to_checksum_address(mkt["gm_token"])
2231
+ raw_bal = w3.eth.contract(address=gm_cs, abi=erc).functions.balanceOf(pa_cs).call()
2232
+ gm_sym = mkt["gm_feed"]
2233
+ if raw_bal == 0 and gm_sym not in owned:
2234
+ continue
2235
+ pos = {"market": key, "kind": "GM+" if mkt["plus"] else "GM", "gm_feed": gm_sym,
2236
+ "gm_token": mkt["gm_token"], "raw": raw_bal, "decimals": GM_TOKEN_DECIMALS,
2237
+ "balance": f"{raw_bal / 10**GM_TOKEN_DECIMALS:.6f}",
2238
+ "after_fees": None, "perf_pct": None, "gm_price_usd": None, "usd": None}
2239
+ # Feeds: GM symbol + underlyings, deduped (GM+ long==short).
2240
+ feeds = [gm_sym, mkt["long"]] + ([] if mkt["plus"] else [mkt["short"]])
2241
+ try:
2242
+ payload = build_redstone_payload(feeds)
2243
+ perf_fn = "getGmPlusPerformance" if mkt["plus"] else "getGmPerformance"
2244
+ after_fees = redstone_view_call(w3, account, "getGmTokenBalanceAfterFees", payload, args=[gm_cs])[0]
2245
+ perf = redstone_view_call(w3, account, perf_fn, payload, args=[gm_cs])[0]
2246
+ gm_usd = _gmx_gm_price_usd(gm_sym)
2247
+ pos["after_fees"] = after_fees / 10**GM_TOKEN_DECIMALS
2248
+ pos["perf_pct"] = perf / 1e16
2249
+ pos["gm_price_usd"] = gm_usd
2250
+ pos["usd"] = after_fees / 10**GM_TOKEN_DECIMALS * gm_usd
2251
+ except Exception as e:
2252
+ pos["error"] = type(e).__name__
2253
+ positions.append(pos)
2254
+ return positions
2255
+
2256
+ def cmd_gmx_positions():
2257
+ w3 = get_w3()
2258
+ acct = get_account()
2259
+ pa = get_prime_account(w3, acct.address)
2260
+ print(f"Owner wallet: {acct.address}")
2261
+ if not pa:
2262
+ print("No Prime Account yet — no GMX LP positions.")
2263
+ return
2264
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
2265
+ print(f"Prime Account: {pa}")
2266
+
2267
+ positions = gather_gmx(w3, account)
2268
+ if not positions:
2269
+ print(" No GM / GM+ LP positions.")
2270
+ return
2271
+ for pos in positions:
2272
+ print(f" [{pos['market']}] {pos['kind']} {pos['gm_feed']} ({pos['gm_token']})")
2273
+ print(f" Raw GM balance: {float(pos['balance']):,.6f}")
2274
+ if pos.get("error"):
2275
+ print(f" Performance/value: RedStone fetch/call failed ({pos['error']})")
2276
+ continue
2277
+ print(f" Balance after fees: {pos['after_fees']:,.6f} "
2278
+ f"(perf fee accrued: {pos['raw'] / 10**GM_TOKEN_DECIMALS - pos['after_fees']:,.6f})")
2279
+ print(f" Annualised perf: {pos['perf_pct']:,.2f}%")
2280
+ print(f" GM price (gateway): ${pos['gm_price_usd']:,.6f} -> position ~${pos['usd']:,.2f}")
2281
+
2282
+ def cmd_gmx_deposit(market: str, amount: float, is_long: bool = True,
2283
+ slippage_pct: float = 1.0, fee_buffer: float = 2.0, execute: bool = False):
2284
+ """Open/add a GMX V2 GM (two-sided) or GM+ (single-sided) LP position by depositing an
2285
+ in-account underlying. Two-sided markets take --side long|short (long = volatile leg,
2286
+ short = USDC); GM+ markets ignore --side. minGmAmount is set to the fair GM amount
2287
+ (depositUSD / gmPrice) minus --slippage, kept within the facet's ±5% isWithinBounds band.
2288
+ PAYABLE + ASYNC: pays a GMX execution fee as msg.value and queues the request; a GMX
2289
+ keeper mints the GM tokens later and the account is frozen until then. RedStone-gated on
2290
+ --execute."""
2291
+ if market not in GMX_MARKETS:
2292
+ print(f"Unknown --market '{market}'. Choose from: {', '.join(GMX_MARKETS)}")
2293
+ return
2294
+ if slippage_pct > GMX_MAX_SLIPPAGE_PCT:
2295
+ print(f"--slippage {slippage_pct}% exceeds the facet's {GMX_MAX_SLIPPAGE_PCT}% isWithinBounds cap; "
2296
+ "the deposit would revert InvalidMinOutputValue. Refusing.")
2297
+ return
2298
+ mkt = GMX_MARKETS[market]
2299
+ dep_sym = mkt["long"] if (mkt["plus"] or is_long) else mkt["short"]
2300
+ dep_cfg = SWAP_ASSETS[dep_sym]
2301
+
2302
+ w3 = get_w3()
2303
+ acct = get_account()
2304
+ pa = get_prime_account(w3, acct.address)
2305
+ if not pa:
2306
+ print("No Prime Account exists for this wallet — nothing to deposit.")
2307
+ print("Create and fund one first: deltaprime create-prime-account --execute")
2308
+ return
2309
+ pa_cs = Web3.to_checksum_address(pa)
2310
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2311
+
2312
+ amount_wei = int(amount * 10**dep_cfg["decimals"])
2313
+ in_balance = account.functions.getBalance(asset_b32(dep_sym)).call()
2314
+ if amount_wei > in_balance:
2315
+ print(f"Prime Account holds only {in_balance / 10**dep_cfg['decimals']:.6f} {dep_sym} "
2316
+ f"in-account; cannot deposit {amount} {dep_sym}.")
2317
+ print("Fund or borrow more of the asset into the account first.")
2318
+ return
2319
+
2320
+ # Fair minGmAmount: depositUSD / gmPrice, scaled to GM decimals, minus slippage.
2321
+ # Two payloads: the underlyings-only one prices the deposit token via SolvencyFacet
2322
+ # getPrices (which has no feed for the GM symbol); the full one (GM feed + underlyings)
2323
+ # is what the write tx and the inline solvency simulation in the facet consume.
2324
+ underlyings = [mkt["long"]] + ([] if mkt["plus"] else [mkt["short"]])
2325
+ price_payload = build_redstone_payload(underlyings)
2326
+ # The facet runs an inline solvency simulation before minting that prices EVERY debt-registry
2327
+ # asset (the full pool set: AVAX/USDC/BTC/ETH/USDT/EUROC, even at zero balance/debt), each
2328
+ # needing 3 unique RedStone signers; a feed missing from the payload reverts the whole deposit
2329
+ # with InsufficientNumberOfUniqueSigners(0,3). So the write payload must cover the full solvency
2330
+ # feed set + the GM feed (deduped) — not just [gm_feed, long, short]. (price_payload above stays
2331
+ # underlyings-only; it's just the off-chain deposit-token price read, which has no GM feed.)
2332
+ _solv_feeds = prime_account_price_feeds(account)
2333
+ _extra_feeds = [f for f in ([mkt["gm_feed"]] + underlyings) if f not in _solv_feeds]
2334
+ payload = build_redstone_payload(_solv_feeds + _extra_feeds)
2335
+ p_dep = _gmx_underlying_price_usd(w3, account, price_payload, dep_sym)
2336
+ gm_usd = _gmx_gm_price_usd(mkt["gm_feed"])
2337
+ deposit_usd = amount_wei / 10**dep_cfg["decimals"] * p_dep / 1e8
2338
+ fair_gm = deposit_usd / gm_usd
2339
+ min_gm = int(fair_gm * (1 - slippage_pct / 100) * 10**GM_TOKEN_DECIMALS)
2340
+ exec_fee, fee_d = _estimate_gmx_execution_fee(w3, is_deposit=True, buffer_mult=fee_buffer)
2341
+ fn = mkt["deposit_fn"]
2342
+
2343
+ kind = "GM+" if mkt["plus"] else "GM"
2344
+ leg = "" if mkt["plus"] else f" ({'long ' + dep_sym if is_long else 'short ' + dep_sym} leg)"
2345
+ print(f"GMX V2 {kind} deposit into [{market}] {mkt['gm_feed']} on Prime Account {pa}")
2346
+ print(f" Deposit: {amount} {dep_sym}{leg} (~${deposit_usd:,.2f})")
2347
+ print(f" Fair GM out: {fair_gm:,.6f} (GM ${gm_usd:,.6f}); minGmAmount @{slippage_pct}% "
2348
+ f"slippage: {min_gm / 10**GM_TOKEN_DECIMALS:,.6f}")
2349
+ print(f" Facet: {fn}(...) — isWithinBounds caps minGmAmount within ±5% of the oracle estimate.")
2350
+ print(f" GMX execution fee: {exec_fee / 1e18:.6f} AVAX (msg.value; {fee_d['buffer_mult']}x of "
2351
+ f"{fee_d['adjusted_gas']:,} gas @ {fee_d['gas_price'] / 1e9:.2f} gwei). Excess is refunded by GMX.")
2352
+ print(" ASYNC: queues a GMX deposit request; a GMX keeper mints the GM tokens in a later")
2353
+ print(f" block. The Prime Account is FROZEN for {mkt['gm_feed']} until the keeper callback fires.")
2354
+ print(f" The EOA must also hold ~{exec_fee / 1e18:.6f} AVAX (gas) on top of the execution fee.")
2355
+
2356
+ if not execute:
2357
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
2358
+ return
2359
+
2360
+ if mkt["plus"]:
2361
+ base_calldata = account.encode_abi(fn, args=[amount_wei, min_gm, exec_fee])
2362
+ else:
2363
+ base_calldata = account.encode_abi(fn, args=[is_long, amount_wei, min_gm, exec_fee])
2364
+ data = base_calldata + payload.hex()
2365
+ tx = {
2366
+ "from": acct.address, "to": pa_cs, "data": data, "value": exec_fee,
2367
+ "nonce": w3.eth.get_transaction_count(acct.address),
2368
+ "gas": 5000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
2369
+ }
2370
+ signed = acct.sign_transaction(tx)
2371
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2372
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
2373
+ ok = receipt["status"] == 1
2374
+ print(f"{'✓' if ok else '✗'} GMX {kind} deposit request {'submitted' if ok else 'failed'}")
2375
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2376
+ if ok:
2377
+ print(" Request queued — wait for the GMX keeper callback to mint the GM tokens.")
2378
+ return ok
2379
+
2380
+ def cmd_gmx_withdraw(market: str, amount: float, slippage_pct: float = 1.0,
2381
+ fee_buffer: float = 2.0, execute: bool = False):
2382
+ """Close/reduce a GMX V2 GM / GM+ LP position by burning --amount GM tokens. The min
2383
+ long/short token floors are derived from the burned GM's USD value split by the market's
2384
+ current reserve ratio (GM, pro-rata redemption) or 50/50 (GM+, single underlying), each
2385
+ minus --slippage and kept within the facet's ±5% isWithinBounds band. PAYABLE + ASYNC:
2386
+ pays a GMX execution fee as msg.value and queues the request; a GMX keeper returns the
2387
+ underlying(s) later and the account is frozen until then. RedStone-gated on --execute."""
2388
+ if market not in GMX_MARKETS:
2389
+ print(f"Unknown --market '{market}'. Choose from: {', '.join(GMX_MARKETS)}")
2390
+ return
2391
+ if slippage_pct > GMX_MAX_SLIPPAGE_PCT:
2392
+ print(f"--slippage {slippage_pct}% exceeds the facet's {GMX_MAX_SLIPPAGE_PCT}% isWithinBounds cap; "
2393
+ "the withdrawal would revert InvalidMinOutputValue. Refusing.")
2394
+ return
2395
+ mkt = GMX_MARKETS[market]
2396
+
2397
+ w3 = get_w3()
2398
+ acct = get_account()
2399
+ pa = get_prime_account(w3, acct.address)
2400
+ if not pa:
2401
+ print("No Prime Account exists for this wallet — no GM position to withdraw.")
2402
+ return
2403
+ pa_cs = Web3.to_checksum_address(pa)
2404
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2405
+
2406
+ gm_cs = Web3.to_checksum_address(mkt["gm_token"])
2407
+ erc = json.loads('[{"inputs":[{"name":"a","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
2408
+ gm_bal = w3.eth.contract(address=gm_cs, abi=erc).functions.balanceOf(pa_cs).call()
2409
+ gm_amount = int(amount * 10**GM_TOKEN_DECIMALS)
2410
+ if gm_bal == 0:
2411
+ print(f"Prime Account holds no {mkt['gm_feed']} GM tokens — nothing to withdraw.")
2412
+ return
2413
+ if gm_amount > gm_bal:
2414
+ print(f"Prime Account holds only {gm_bal / 10**GM_TOKEN_DECIMALS:,.6f} GM; "
2415
+ f"clamping withdrawal to that (the facet caps to balance anyway).")
2416
+ gm_amount = gm_bal
2417
+
2418
+ # Underlyings-only payload for the SolvencyFacet price reads (no GM feed there).
2419
+ underlyings = [mkt["long"]] + ([] if mkt["plus"] else [mkt["short"]])
2420
+ price_payload = build_redstone_payload(underlyings)
2421
+ # Write payload: the facet's inline solvency check prices the FULL debt registry
2422
+ # (every pool, even at zero balance), each needing 3 RedStone signers — so it must
2423
+ # carry prime_account_price_feeds + the GM feed, or it reverts with
2424
+ # InsufficientNumberOfUniqueSigners(0,3). (Same fix as cmd_gmx_deposit.)
2425
+ _solv_feeds = prime_account_price_feeds(account)
2426
+ _extra_feeds = [f for f in ([mkt["gm_feed"]] + underlyings) if f not in _solv_feeds]
2427
+ payload = build_redstone_payload(_solv_feeds + _extra_feeds)
2428
+ long_cfg = SWAP_ASSETS[mkt["long"]]
2429
+ short_cfg = SWAP_ASSETS[mkt["short"]]
2430
+ p_long = _gmx_underlying_price_usd(w3, account, price_payload, mkt["long"])
2431
+ p_short = p_long if mkt["plus"] else _gmx_underlying_price_usd(w3, account, price_payload, mkt["short"])
2432
+ gm_usd = _gmx_gm_price_usd(mkt["gm_feed"])
2433
+ burn_usd = gm_amount / 10**GM_TOKEN_DECIMALS * gm_usd
2434
+
2435
+ slip = 1 - slippage_pct / 100
2436
+ if mkt["plus"]:
2437
+ # GM+: single underlying, 50/50 split as before
2438
+ long_frac, short_frac = 0.5, 0.5
2439
+ min_long = int((burn_usd * long_frac) / (p_long / 1e8) * slip * 10**long_cfg["decimals"])
2440
+ min_short = int((burn_usd * short_frac) / (p_short / 1e8) * slip * 10**short_cfg["decimals"])
2441
+ else:
2442
+ # GM two-sided: use GMX Reader to get the correct redemption split ratio, then
2443
+ # apply it to burn_usd (the GM value our facet sees). This avoids the
2444
+ # reserve-based split divergence that causes keeper cancellation.
2445
+ expected_long, expected_short = _gmx_withdrawal_amount_out(w3, account, mkt, gm_amount, p_long, p_short)
2446
+ expected_long_usd = expected_long / 10**long_cfg["decimals"] * p_long / 1e8
2447
+ expected_short_usd = expected_short / 10**short_cfg["decimals"] * p_short / 1e8
2448
+ expected_total_usd = expected_long_usd + expected_short_usd
2449
+ long_frac = expected_long_usd / expected_total_usd if expected_total_usd > 0 else 0.5
2450
+ short_frac = expected_short_usd / expected_total_usd if expected_total_usd > 0 else 0.5
2451
+ min_long = int((burn_usd * long_frac) / (p_long / 1e8) * slip * 10**long_cfg["decimals"])
2452
+ min_short = int((burn_usd * short_frac) / (p_short / 1e8) * slip * 10**short_cfg["decimals"])
2453
+ exec_fee, fee_d = _estimate_gmx_execution_fee(w3, is_deposit=False, buffer_mult=fee_buffer)
2454
+ fn = mkt["withdraw_fn"]
2455
+
2456
+ kind = "GM+" if mkt["plus"] else "GM"
2457
+ print(f"GMX V2 {kind} withdraw from [{market}] {mkt['gm_feed']} on Prime Account {pa}")
2458
+ print(f" Burn: {gm_amount / 10**GM_TOKEN_DECIMALS:,.6f} GM (~${burn_usd:,.2f}; GM ${gm_usd:,.6f})")
2459
+ if mkt["plus"]:
2460
+ tot_min = (min_long / 10**long_cfg["decimals"]) + (min_short / 10**short_cfg["decimals"])
2461
+ print(f" Min {mkt['long']} out @{slippage_pct}% slippage: {tot_min:,.6f} "
2462
+ f"(facet sums minLong {min_long / 10**long_cfg['decimals']:,.6f} + "
2463
+ f"minShort {min_short / 10**short_cfg['decimals']:,.6f}, both the single underlying)")
2464
+ else:
2465
+ print(f" Expected {mkt['long']}: {expected_long / 10**long_cfg['decimals']:,.6f} Expected {mkt['short']}: {expected_short / 10**short_cfg['decimals']:,.6f}")
2466
+ print(f" Min {mkt['long']} out @{slippage_pct}% slippage: {min_long / 10**long_cfg['decimals']:,.6f}")
2467
+ print(f" Min {mkt['short']} out @{slippage_pct}% slippage: {min_short / 10**short_cfg['decimals']:,.6f}")
2468
+ print(f" Facet: {fn}(...) — isWithinBounds caps the min-out USD within ±5% of the oracle estimate.")
2469
+ print(f" GMX execution fee: {exec_fee / 1e18:.6f} AVAX (msg.value; {fee_d['buffer_mult']}x of "
2470
+ f"{fee_d['adjusted_gas']:,} gas @ {fee_d['gas_price'] / 1e9:.2f} gwei). Excess is refunded by GMX.")
2471
+ print(" ASYNC: queues a GMX withdrawal request; a GMX keeper returns the underlying(s) in")
2472
+ print(f" a later block. The Prime Account is FROZEN for {mkt['gm_feed']} until the callback fires.")
2473
+ print(f" The EOA must also hold ~{exec_fee / 1e18:.6f} AVAX (gas) on top of the execution fee.")
2474
+
2475
+ if not execute:
2476
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
2477
+ return
2478
+
2479
+ base_calldata = account.encode_abi(fn, args=[gm_amount, min_long, min_short, exec_fee])
2480
+ data = base_calldata + payload.hex()
2481
+ tx = {
2482
+ "from": acct.address, "to": pa_cs, "data": data, "value": exec_fee,
2483
+ "nonce": w3.eth.get_transaction_count(acct.address),
2484
+ "gas": 5000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
2485
+ }
2486
+ signed = acct.sign_transaction(tx)
2487
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2488
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
2489
+ ok = receipt["status"] == 1
2490
+ print(f"{'✓' if ok else '✗'} GMX {kind} withdrawal request {'submitted' if ok else 'failed'}")
2491
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2492
+ if ok:
2493
+ print(" Request queued — wait for the GMX keeper callback to return the underlying(s).")
2494
+
2495
+ # ─── TraderJoe V2 Liquidity Book (TraderJoeV2AvalancheFacet) ─────────────────
2496
+ # Concentrated liquidity across discrete price bins. addLiquidityTraderJoeV2 encodes a
2497
+ # position via deltaIds[] (bin offsets from the active bin) + distributionX[]/distributionY[]
2498
+ # (per-bin token weightings, each side summing to 1e18). The shape (Spot/Curve/Bid-Ask) is
2499
+ # only those distribution arrays over the bin range. Token X (the base) is placed in bins
2500
+ # at/above the active bin (deltaId >= 0); token Y (the quote) in bins at/below it
2501
+ # (deltaId <= 0); the active bin (deltaId 0) can carry both. addLiquidity is RedStone-gated
2502
+ # (remainsSolvent); removeLiquidity is not. Max 80 bins per Prime Account, cumulative.
2503
+
2504
+ LB_ONE = 10**18 # 1e18 == 100% in the distribution arrays
2505
+
2506
+
2507
+ def _lb_pair_contract(w3, pair_addr):
2508
+ return w3.eth.contract(address=Web3.to_checksum_address(pair_addr), abi=LB_PAIR_ABI)
2509
+
2510
+
2511
+ def _lb_shape_weights(n: int, shape: str) -> list:
2512
+ """Relative (un-normalised) weights for n bins on ONE token side, ordered from the
2513
+ active bin outward to the edge (index 0 == nearest the active price). Spot = uniform;
2514
+ Curve = concentrated near the active price (linear decay to the edge); Bid-Ask =
2515
+ concentrated at the edge (linear rise outward)."""
2516
+ if n <= 0:
2517
+ return []
2518
+ if n == 1 or shape == "spot":
2519
+ return [1.0] * n
2520
+ if shape == "curve":
2521
+ # Heaviest nearest the active bin, lightest at the far edge.
2522
+ return [float(n - i) for i in range(n)]
2523
+ if shape == "bidask":
2524
+ # Lightest nearest the active bin, heaviest at the far edge.
2525
+ return [float(i + 1) for i in range(n)]
2526
+ raise ValueError(f"unknown shape '{shape}'")
2527
+
2528
+
2529
+ def _lb_normalise(weights: list) -> list:
2530
+ """Scale relative weights to integers summing to exactly 1e18 (the router requires each
2531
+ populated side to sum to 1e18). Any rounding remainder is folded into the largest bin so
2532
+ the sum is exact."""
2533
+ if not weights:
2534
+ return []
2535
+ total = sum(weights)
2536
+ out = [int(w / total * LB_ONE) for w in weights]
2537
+ out[max(range(len(out)), key=lambda i: out[i])] += LB_ONE - sum(out)
2538
+ return out
2539
+
2540
+
2541
+ def _lb_build_distributions(active_id: int, range_bins: int, shape: str,
2542
+ has_x: bool, has_y: bool):
2543
+ """Build (deltaIds, distributionX, distributionY) for a position spanning range_bins on
2544
+ each side of the active bin. tokenX fills deltaId>=0 bins, tokenY fills deltaId<=0; the
2545
+ active bin (deltaId 0) is shared. Distributions sum to 1e18 on each populated side."""
2546
+ deltas = list(range(-range_bins, range_bins + 1))
2547
+
2548
+ # Per-side bin lists, ordered from the active bin outward (so shape weights line up).
2549
+ x_deltas = [d for d in deltas if d >= 0] # 0, +1, ... (outward)
2550
+ y_deltas = sorted([d for d in deltas if d <= 0], reverse=True) # 0, -1, ... (outward)
2551
+ x_w = _lb_normalise(_lb_shape_weights(len(x_deltas), shape)) if has_x else [0] * len(x_deltas)
2552
+ y_w = _lb_normalise(_lb_shape_weights(len(y_deltas), shape)) if has_y else [0] * len(y_deltas)
2553
+ x_by_delta = dict(zip(x_deltas, x_w))
2554
+ y_by_delta = dict(zip(y_deltas, y_w))
2555
+
2556
+ dist_x = [x_by_delta.get(d, 0) for d in deltas]
2557
+ dist_y = [y_by_delta.get(d, 0) for d in deltas]
2558
+ return deltas, dist_x, dist_y
2559
+
2560
+
2561
+ def gather_lb(w3, account):
2562
+ """Read-only TraderJoe V2 LB positions on a Prime Account. getOwnedTraderJoeV2Bins
2563
+ (oracle-free) gives the (pair, binId) list; per pair we read the active bin and the
2564
+ account's share of each owned bin's reserves (balanceOf / totalSupply * getBin) to derive
2565
+ the per-token totals. No RedStone, no tx. Returns a list of per-pair dicts (empty if none).
2566
+ Shared by cmd_lb_positions (print) and cmd_defi (--json)."""
2567
+ pa_cs = account.address
2568
+ bins = account.functions.getOwnedTraderJoeV2Bins().call()
2569
+ if not bins:
2570
+ return []
2571
+ # Pair address -> tool key + token metadata (for labels + decimals).
2572
+ by_addr = {Web3.to_checksum_address(p["pair"]): (key, p) for key, p in TJ_LB_PAIRS.items()}
2573
+ # Group owned bins by pair, preserving the canonical pair token order.
2574
+ grouped = {}
2575
+ for pair, binid in bins:
2576
+ grouped.setdefault(Web3.to_checksum_address(pair), []).append(int(binid))
2577
+
2578
+ out = []
2579
+ for pair_cs, ids in grouped.items():
2580
+ ids.sort()
2581
+ meta = by_addr.get(pair_cs)
2582
+ c = _lb_pair_contract(w3, pair_cs)
2583
+ try:
2584
+ active = c.functions.getActiveId().call()
2585
+ tx_addr = Web3.to_checksum_address(c.functions.getTokenX().call())
2586
+ ty_addr = Web3.to_checksum_address(c.functions.getTokenY().call())
2587
+ except Exception as e:
2588
+ out.append({"pair": pair_cs, "error": type(e).__name__})
2589
+ continue
2590
+ if meta:
2591
+ key, p = meta
2592
+ x_cfg, y_cfg = p["tokenX"], p["tokenY"]
2593
+ label = f"[{key}] {x_cfg['symbol']}/{y_cfg['symbol']} (binStep {p['binStep']})"
2594
+ else:
2595
+ # Unmapped (e.g. an aUSD pair) — fall back to raw addresses + 18 decimals.
2596
+ x_cfg = {"symbol": tx_addr[:8], "decimals": 18}
2597
+ y_cfg = {"symbol": ty_addr[:8], "decimals": 18}
2598
+ label = f"[pair {pair_cs}]"
2599
+ sum_x = sum_y = 0.0
2600
+ for binid in ids:
2601
+ try:
2602
+ bal = c.functions.balanceOf(pa_cs, binid).call()
2603
+ ts = c.functions.totalSupply(binid).call()
2604
+ rx, ry = c.functions.getBin(binid).call()
2605
+ except Exception:
2606
+ continue
2607
+ share = (bal / ts) if ts else 0
2608
+ sum_x += rx * share / 10**x_cfg["decimals"]
2609
+ sum_y += ry * share / 10**y_cfg["decimals"]
2610
+ out.append({"pair": pair_cs, "label": label, "active_bin": int(active),
2611
+ "bins": len(ids), "bin_range": [ids[0], ids[-1]],
2612
+ "token_x": {"symbol": x_cfg["symbol"], "amount": sum_x},
2613
+ "token_y": {"symbol": y_cfg["symbol"], "amount": sum_y}})
2614
+ return out
2615
+
2616
+ def cmd_lb_positions():
2617
+ w3 = get_w3()
2618
+ acct = get_account()
2619
+ pa = get_prime_account(w3, acct.address)
2620
+ print(f"Owner wallet: {acct.address}")
2621
+ if not pa:
2622
+ print("No Prime Account yet — no TraderJoe LB positions.")
2623
+ return
2624
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
2625
+ print(f"Prime Account: {pa}")
2626
+
2627
+ positions = gather_lb(w3, account)
2628
+ if not positions:
2629
+ print(" No TraderJoe V2 LB bins owned.")
2630
+ return
2631
+ total = sum(p.get("bins", 0) for p in positions)
2632
+ print(f" Owned bins: {total} / {TJ_MAX_BINS} max")
2633
+ for p in positions:
2634
+ if p.get("error"):
2635
+ print(f" [pair {p['pair']}] read failed ({p['error']})")
2636
+ continue
2637
+ lo, hi, act = p['bin_range'][0], p['bin_range'][1], p['active_bin']
2638
+ if lo <= act <= hi:
2639
+ rng = f"IN RANGE (active at bin {act - lo + 1} of {hi - lo + 1})"
2640
+ elif act < lo:
2641
+ rng = (f"OUT OF RANGE — active {lo - act} bin(s) below your range, "
2642
+ f"position now ~all {p['token_x']['symbol']}, earning NO fees")
2643
+ else:
2644
+ rng = (f"OUT OF RANGE — active {act - hi} bin(s) above your range, "
2645
+ f"position now ~all {p['token_y']['symbol']}, earning NO fees")
2646
+ print(f" {p['label']} {p['bins']} bin(s) — {rng}")
2647
+ print(f" Totals: {p['token_x']['amount']:,.6f} {p['token_x']['symbol']} + "
2648
+ f"{p['token_y']['amount']:,.6f} {p['token_y']['symbol']} "
2649
+ f"across bins {lo}..{hi} (active {act}). Value skew: see `defi --json`.")
2650
+
2651
+
2652
+ def cmd_lb_add(pair_key: str, amount_x: float, amount_y: float, shape: str = "spot",
2653
+ range_bins: int = 5, slippage_pct: float = 1.0, id_slippage: int = 5,
2654
+ execute: bool = False):
2655
+ """Add TraderJoe V2 LB liquidity for a whitelisted pair. Distributes amount_x (token X)
2656
+ and amount_y (token Y) across `range_bins` bins on each side of the active bin per the
2657
+ chosen shape (spot|curve|bidask). amountXMin/amountYMin are slippage floors on the
2658
+ total deposited; idSlippage guards the active-bin id moving before inclusion. Refuses if
2659
+ no Prime Account, if either token's in-account balance is short, or if the resulting bin
2660
+ count would exceed the 80-bin cap. RedStone-gated on --execute (addLiquidity carries
2661
+ remainsSolvent)."""
2662
+ pair_key = pair_key.lower()
2663
+ if pair_key not in TJ_LB_PAIRS:
2664
+ print(f"Unknown --pair '{pair_key}'. Choose from: {', '.join(TJ_LB_PAIRS)}")
2665
+ return
2666
+ shape = shape.lower()
2667
+ if shape not in ("spot", "curve", "bidask"):
2668
+ print("--shape must be spot, curve or bidask.")
2669
+ return
2670
+ if range_bins < 0:
2671
+ print("--range must be >= 0.")
2672
+ return
2673
+ if amount_x <= 0 and amount_y <= 0:
2674
+ print("Provide --amount-x and/or --amount-y (at least one must be > 0).")
2675
+ return
2676
+
2677
+ p = TJ_LB_PAIRS[pair_key]
2678
+ x_cfg, y_cfg = p["tokenX"], p["tokenY"]
2679
+ has_x, has_y = amount_x > 0, amount_y > 0
2680
+ n_bins = 2 * range_bins + 1
2681
+ if n_bins > TJ_MAX_BINS:
2682
+ print(f"--range {range_bins} spans {n_bins} bins, over the {TJ_MAX_BINS}-bin cap. "
2683
+ f"Use --range <= {(TJ_MAX_BINS - 1) // 2}.")
2684
+ return
2685
+
2686
+ w3 = get_w3()
2687
+ acct = get_account()
2688
+ pa = get_prime_account(w3, acct.address)
2689
+ if not pa:
2690
+ print("No Prime Account exists for this wallet — nothing to LP.")
2691
+ print("Create and fund one first: deltaprime create-prime-account --execute")
2692
+ return
2693
+ pa_cs = Web3.to_checksum_address(pa)
2694
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2695
+
2696
+ amount_x_wei = int(amount_x * 10**x_cfg["decimals"]) if has_x else 0
2697
+ amount_y_wei = int(amount_y * 10**y_cfg["decimals"]) if has_y else 0
2698
+
2699
+ # In-account balances (oracle-free), keyed by the TokenManager symbol the facet uses.
2700
+ bal_x = account.functions.getBalance(asset_b32(x_cfg["symbol"])).call()
2701
+ bal_y = account.functions.getBalance(asset_b32(y_cfg["symbol"])).call()
2702
+ if amount_x_wei > bal_x:
2703
+ print(f"Prime Account holds only {bal_x / 10**x_cfg['decimals']:.6f} {x_cfg['symbol']} "
2704
+ f"in-account; cannot add {amount_x} {x_cfg['symbol']}.")
2705
+ return
2706
+ if amount_y_wei > bal_y:
2707
+ print(f"Prime Account holds only {bal_y / 10**y_cfg['decimals']:.6f} {y_cfg['symbol']} "
2708
+ f"in-account; cannot add {amount_y} {y_cfg['symbol']}.")
2709
+ return
2710
+
2711
+ # Cumulative 80-bin cap: bins this pair already owns are re-used (not double-counted);
2712
+ # only NET-NEW bins grow the count. Approximate conservatively by counting bins on OTHER
2713
+ # pairs as fixed and assuming all n_bins here are new (worst case).
2714
+ owned = account.functions.getOwnedTraderJoeV2Bins().call()
2715
+ owned_here = {int(b) for pr, b in owned if Web3.to_checksum_address(pr) == Web3.to_checksum_address(p["pair"])}
2716
+ owned_other = len(owned) - len(owned_here)
2717
+
2718
+ pair_c = _lb_pair_contract(w3, p["pair"])
2719
+ active_id = pair_c.functions.getActiveId().call()
2720
+
2721
+ deltas, dist_x, dist_y = _lb_build_distributions(active_id, range_bins, shape, has_x, has_y)
2722
+ target_ids = {active_id + d for d in deltas}
2723
+ new_bins = len([i for i in target_ids if i not in owned_here])
2724
+ projected = owned_other + len(owned_here) + new_bins
2725
+ bin_ids_sorted = sorted(target_ids)
2726
+
2727
+ amount_x_min = int(amount_x_wei * (1 - slippage_pct / 100))
2728
+ amount_y_min = int(amount_y_wei * (1 - slippage_pct / 100))
2729
+
2730
+ print(f"TraderJoe V2 LB add into [{pair_key}] {x_cfg['symbol']}/{y_cfg['symbol']} "
2731
+ f"(binStep {p['binStep']}) on Prime Account {pa}")
2732
+ print(f" Router: {p['router']} ({'v2.1' if p['router'] == TJ_ROUTER_V21 else 'v2.2'})")
2733
+ print(f" Shape: {shape} | range: ±{range_bins} bins ({n_bins} bins, ids {bin_ids_sorted[0]}..{bin_ids_sorted[-1]})")
2734
+ print(f" Active bin: {active_id} (idSlippage ±{id_slippage})")
2735
+ if has_x:
2736
+ print(f" Deposit X: {amount_x} {x_cfg['symbol']} (min {amount_x_min / 10**x_cfg['decimals']:.6f} @{slippage_pct}%) "
2737
+ f"-> bins deltaId >= 0")
2738
+ if has_y:
2739
+ print(f" Deposit Y: {amount_y} {y_cfg['symbol']} (min {amount_y_min / 10**y_cfg['decimals']:.6f} @{slippage_pct}%) "
2740
+ f"-> bins deltaId <= 0")
2741
+ # Distribution summary: show the non-zero weighting per side as percentages.
2742
+ if has_x:
2743
+ xs = [(active_id + d, dist_x[i] / LB_ONE * 100) for i, d in enumerate(deltas) if dist_x[i] > 0]
2744
+ print(f" distributionX: " + ", ".join(f"{bid}:{pct:.1f}%" for bid, pct in xs))
2745
+ if has_y:
2746
+ ys = [(active_id + d, dist_y[i] / LB_ONE * 100) for i, d in enumerate(deltas) if dist_y[i] > 0]
2747
+ print(f" distributionY: " + ", ".join(f"{bid}:{pct:.1f}%" for bid, pct in ys))
2748
+ print(f" Bins: {len(owned_here)} already owned on this pair, {new_bins} net-new "
2749
+ f"-> projected total {projected} / {TJ_MAX_BINS}")
2750
+ if projected > TJ_MAX_BINS:
2751
+ print(f" ✗ Would exceed the {TJ_MAX_BINS}-bin cap (the facet reverts TooManyBins). "
2752
+ f"Reduce --range or remove other bins first. Refusing.")
2753
+ return
2754
+ print(" The facet overrides to/refundTo to the account and re-checks the 80-bin cap on-chain.")
2755
+
2756
+ if not execute:
2757
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
2758
+ return
2759
+
2760
+ # remainsSolvent gating: append a RedStone payload covering the account's assets + debts
2761
+ # plus both LB tokens. EURC's RedStone feed id is its account symbol "EUROC".
2762
+ feeds = prime_account_price_feeds(account)
2763
+ for s in (x_cfg["symbol"], y_cfg["symbol"]):
2764
+ if s not in feeds:
2765
+ feeds.append(s)
2766
+ payload = build_redstone_payload(feeds)
2767
+
2768
+ deadline = int(time.time()) + 1200
2769
+ # to/refundTo are overridden by the facet; pass the account anyway for a clean preview.
2770
+ liquidity_params = (
2771
+ Web3.to_checksum_address(x_cfg["addr"]), Web3.to_checksum_address(y_cfg["addr"]),
2772
+ p["binStep"], amount_x_wei, amount_y_wei, amount_x_min, amount_y_min,
2773
+ active_id, id_slippage, deltas, dist_x, dist_y, pa_cs, pa_cs, deadline,
2774
+ )
2775
+ base_calldata = account.encode_abi("addLiquidityTraderJoeV2",
2776
+ args=[Web3.to_checksum_address(p["router"]), liquidity_params])
2777
+ data = base_calldata + payload.hex()
2778
+ tx = {
2779
+ "from": acct.address, "to": pa_cs, "data": data,
2780
+ "nonce": w3.eth.get_transaction_count(acct.address),
2781
+ "gas": 5000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
2782
+ }
2783
+ signed = acct.sign_transaction(tx)
2784
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2785
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
2786
+ ok = receipt["status"] == 1
2787
+ print(f"{'✓' if ok else '✗'} LB add {'confirmed' if ok else 'failed'}")
2788
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2789
+ return ok
2790
+
2791
+
2792
+ def cmd_lb_remove(pair_key: str, slippage_pct: float = 1.0, execute: bool = False):
2793
+ """Remove ALL of the Prime Account's TraderJoe V2 LB liquidity for a whitelisted pair.
2794
+ Reads the owned bin ids for the pair (getOwnedTraderJoeV2Bins) and the account's LB
2795
+ balance per bin, then calls removeLiquidityTraderJoeV2 with those ids+amounts.
2796
+ amountXMin/amountYMin are slippage floors on the totals withdrawn, derived from the
2797
+ account's current share of each bin's reserves. removeLiquidity is NOT solvency-gated,
2798
+ so no RedStone payload is appended. Refuses if no Prime Account or no position."""
2799
+ pair_key = pair_key.lower()
2800
+ if pair_key not in TJ_LB_PAIRS:
2801
+ print(f"Unknown --pair '{pair_key}'. Choose from: {', '.join(TJ_LB_PAIRS)}")
2802
+ return
2803
+
2804
+ p = TJ_LB_PAIRS[pair_key]
2805
+ x_cfg, y_cfg = p["tokenX"], p["tokenY"]
2806
+
2807
+ w3 = get_w3()
2808
+ acct = get_account()
2809
+ pa = get_prime_account(w3, acct.address)
2810
+ if not pa:
2811
+ print("No Prime Account exists for this wallet — no LB position to remove.")
2812
+ return
2813
+ pa_cs = Web3.to_checksum_address(pa)
2814
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2815
+
2816
+ pair_cs = Web3.to_checksum_address(p["pair"])
2817
+ owned = account.functions.getOwnedTraderJoeV2Bins().call()
2818
+ ids = sorted({int(b) for pr, b in owned if Web3.to_checksum_address(pr) == pair_cs})
2819
+ if not ids:
2820
+ print(f"Prime Account owns no {x_cfg['symbol']}/{y_cfg['symbol']} LB bins on [{pair_key}].")
2821
+ return
2822
+
2823
+ pair_c = _lb_pair_contract(w3, pair_cs)
2824
+ active_id = pair_c.functions.getActiveId().call()
2825
+ amounts = []
2826
+ est_x = est_y = 0.0
2827
+ for binid in ids:
2828
+ bal = pair_c.functions.balanceOf(pa_cs, binid).call()
2829
+ ts = pair_c.functions.totalSupply(binid).call()
2830
+ rx, ry = pair_c.functions.getBin(binid).call()
2831
+ amounts.append(bal)
2832
+ share = (bal / ts) if ts else 0
2833
+ est_x += rx * share / 10**x_cfg["decimals"]
2834
+ est_y += ry * share / 10**y_cfg["decimals"]
2835
+
2836
+ if all(a == 0 for a in amounts):
2837
+ print(f"All {len(ids)} tracked bins on [{pair_key}] hold zero LB balance — nothing to remove.")
2838
+ return
2839
+
2840
+ amount_x_min = int(est_x * (1 - slippage_pct / 100) * 10**x_cfg["decimals"])
2841
+ amount_y_min = int(est_y * (1 - slippage_pct / 100) * 10**y_cfg["decimals"])
2842
+
2843
+ print(f"TraderJoe V2 LB remove from [{pair_key}] {x_cfg['symbol']}/{y_cfg['symbol']} "
2844
+ f"(binStep {p['binStep']}) on Prime Account {pa}")
2845
+ print(f" Router: {p['router']} ({'v2.1' if p['router'] == TJ_ROUTER_V21 else 'v2.2'})")
2846
+ print(f" Active bin: {active_id} | removing {len(ids)} bin(s): {ids[0]}..{ids[-1]}")
2847
+ print(f" Est. out: {est_x:,.6f} {x_cfg['symbol']} + {est_y:,.6f} {y_cfg['symbol']}")
2848
+ print(f" Mins @{slippage_pct}%: {amount_x_min / 10**x_cfg['decimals']:.6f} {x_cfg['symbol']} + "
2849
+ f"{amount_y_min / 10**y_cfg['decimals']:.6f} {y_cfg['symbol']}")
2850
+ print(" removeLiquidity is NOT solvency-gated per the facet source, but on-chain calls revert without a RedStone payload — appending one.")
2851
+
2852
+ if not execute:
2853
+ print("Run with --execute to broadcast.")
2854
+ return
2855
+
2856
+ deadline = int(time.time()) + 1200
2857
+ remove_params = (
2858
+ Web3.to_checksum_address(x_cfg["addr"]), Web3.to_checksum_address(y_cfg["addr"]),
2859
+ p["binStep"], amount_x_min, amount_y_min, ids, amounts, deadline,
2860
+ )
2861
+ # Despite the comment that removeLiquidityTraderJoeV2 is NOT remainsSolvent,
2862
+ # on-chain calls revert with 0xe7764c9e (missing RedStone price data) without a
2863
+ # payload. Append the full account price-feeds set to be safe (tested 2026-05-24).
2864
+ feeds = prime_account_price_feeds(account)
2865
+ for s in (x_cfg["symbol"], y_cfg["symbol"]):
2866
+ if s not in feeds:
2867
+ feeds.append(s)
2868
+ payload = build_redstone_payload(feeds)
2869
+ base_calldata = account.encode_abi("removeLiquidityTraderJoeV2",
2870
+ args=[Web3.to_checksum_address(p["router"]), remove_params])
2871
+ data = base_calldata + payload.hex()
2872
+ tx = {
2873
+ "from": acct.address, "to": pa_cs, "data": data,
2874
+ "nonce": w3.eth.get_transaction_count(acct.address),
2875
+ "gas": 5000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
2876
+ }
2877
+ signed = acct.sign_transaction(tx)
2878
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2879
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
2880
+ ok = receipt["status"] == 1
2881
+ print(f"{'✓' if ok else '✗'} LB remove {'confirmed' if ok else 'failed'}")
2882
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2883
+
2884
+ # ─── sJOE staking (SJoeFacet) ────────────────────────────────────────────────
2885
+ # Stake in-account JOE into TraderJoe's sJOE for USDC fee rewards, unstake it back, or claim
2886
+ # accrued USDC. All run on the Prime Account. stakeJoe + claimSJoeRewards carry remainsSolvent
2887
+ # so --execute appends a RedStone signed-price payload; unstakeJoe does not (onlyOwnerOrInsolvent),
2888
+ # matching the lb-remove no-payload pattern. The two position views are oracle-free.
2889
+
2890
+ def gather_sjoe(account):
2891
+ """Read-only sJOE staking position on a Prime Account. joeBalanceInSJoe (staked JOE) and
2892
+ rewardsInSJoe (pending USDC rewards) are oracle-free SJoeFacet views — no RedStone, no tx.
2893
+ Returns a dict with the raw + formatted staked/pending amounts. Shared by cmd_sjoe_position
2894
+ (print) and cmd_defi (--json)."""
2895
+ staked = account.functions.joeBalanceInSJoe().call()
2896
+ pending = account.functions.rewardsInSJoe().call()
2897
+ return {
2898
+ "staked_raw": staked, "pending_raw": pending,
2899
+ "staked": f"{staked / 10**SJOE_JOE['decimals']:.6f}",
2900
+ "pending": f"{pending / 10**SJOE_REWARD['decimals']:.6f}",
2901
+ "joe_symbol": SJOE_JOE["symbol"], "reward_symbol": SJOE_REWARD["symbol"],
2902
+ }
2903
+
2904
+ def cmd_sjoe_position():
2905
+ w3 = get_w3()
2906
+ acct = get_account()
2907
+ pa = get_prime_account(w3, acct.address)
2908
+ print(f"Owner wallet: {acct.address}")
2909
+ if not pa:
2910
+ print("No Prime Account yet — no sJOE staking position.")
2911
+ return
2912
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI)
2913
+ print(f"Prime Account: {pa}")
2914
+
2915
+ s = gather_sjoe(account)
2916
+ staked, pending = s["staked_raw"], s["pending_raw"]
2917
+ print(f" Staked JOE: {staked / 10**SJOE_JOE['decimals']:,.6f} JOE")
2918
+ print(f" Pending rewards: {pending / 10**SJOE_REWARD['decimals']:,.6f} USDC", end="")
2919
+ if pending > 0:
2920
+ net = pending * (1 - SJOE_CLAIMING_FEE_PCT / 100)
2921
+ print(f" (~{net / 10**SJOE_REWARD['decimals']:,.6f} net after the {SJOE_CLAIMING_FEE_PCT:.0f}% claim fee)")
2922
+ else:
2923
+ print()
2924
+ if staked == 0 and pending == 0:
2925
+ print(" No active sJOE position.")
2926
+
2927
+ def cmd_sjoe_stake(amount: float, execute: bool = False):
2928
+ """Stake JOE from the Prime Account's in-account balance into sJOE (stakeJoe). Carries
2929
+ remainsSolvent, so --execute appends a fresh RedStone price payload. The facet caps the
2930
+ amount to the account's in-account JOE; staking also auto-claims any pending USDC (net of
2931
+ the 10% claim fee). Preview by default."""
2932
+ w3 = get_w3()
2933
+ acct = get_account()
2934
+ pa = get_prime_account(w3, acct.address)
2935
+ if not pa:
2936
+ print("No Prime Account exists for this wallet — nothing to stake.")
2937
+ print("Create and fund one first: deltaprime create-prime-account --execute")
2938
+ return
2939
+ pa_cs = Web3.to_checksum_address(pa)
2940
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2941
+
2942
+ amount_wei = int(amount * 10**SJOE_JOE["decimals"])
2943
+ in_balance = account.functions.getBalance(asset_b32(SJOE_JOE["symbol"])).call()
2944
+ print(f"sJOE stake: {amount} JOE from Prime Account {pa}")
2945
+ print(f" In-account JOE: {in_balance / 10**SJOE_JOE['decimals']:,.6f}")
2946
+ if amount_wei > in_balance:
2947
+ print(f" ✗ Requested {amount} JOE exceeds the in-account balance "
2948
+ f"(facet reverts 'Not enough JOE to stake'). Refusing.")
2949
+ print(" Fund or swap JOE into the account first.")
2950
+ return
2951
+ pending = account.functions.rewardsInSJoe().call()
2952
+ print(f" Calls stakeJoe({amount_wei}) on the Prime Account.")
2953
+ print(f" Pending USDC auto-claimed on stake: {pending / 10**SJOE_REWARD['decimals']:,.6f} "
2954
+ f"(net of the {SJOE_CLAIMING_FEE_PCT:.0f}% claim fee).")
2955
+ print(" Carries remainsSolvent — appends a fresh RedStone payload on --execute.")
2956
+
2957
+ if not execute:
2958
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
2959
+ return
2960
+
2961
+ # remainsSolvent gating: cover the account's assets + debts plus JOE (about to be staked)
2962
+ # and USDC (the reward asset the facet adds on claim). Fetched fresh — valid ~3 minutes.
2963
+ feeds = prime_account_price_feeds(account)
2964
+ for s in (SJOE_JOE["symbol"], SJOE_REWARD["symbol"]):
2965
+ if s not in feeds:
2966
+ feeds.append(s)
2967
+ payload = build_redstone_payload(feeds)
2968
+ data = account.encode_abi("stakeJoe", args=[amount_wei]) + payload.hex()
2969
+ tx = {
2970
+ "from": acct.address, "to": pa_cs, "data": data,
2971
+ "nonce": w3.eth.get_transaction_count(acct.address),
2972
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
2973
+ }
2974
+ signed = acct.sign_transaction(tx)
2975
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
2976
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
2977
+ ok = receipt["status"] == 1
2978
+ print(f"{'✓' if ok else '✗'} sJOE stake {amount} JOE {'confirmed' if ok else 'failed'}")
2979
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
2980
+
2981
+ def cmd_sjoe_unstake(amount: float, execute: bool = False):
2982
+ """Unstake JOE from sJOE back into the Prime Account (unstakeJoe). NOT remainsSolvent
2983
+ (onlyOwnerOrInsolvent), so no RedStone payload is appended — same as lb-remove. Caps to the
2984
+ staked balance; unstaking also auto-claims pending USDC (net of the 10% claim fee). Preview
2985
+ by default."""
2986
+ w3 = get_w3()
2987
+ acct = get_account()
2988
+ pa = get_prime_account(w3, acct.address)
2989
+ if not pa:
2990
+ print("No Prime Account exists for this wallet — nothing to unstake.")
2991
+ return
2992
+ pa_cs = Web3.to_checksum_address(pa)
2993
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
2994
+
2995
+ staked = account.functions.joeBalanceInSJoe().call()
2996
+ if staked == 0:
2997
+ print(f"Prime Account {pa} has no staked JOE in sJOE — nothing to unstake.")
2998
+ return
2999
+ amount_wei = int(amount * 10**SJOE_JOE["decimals"])
3000
+ if amount_wei > staked:
3001
+ print(f"Staked balance is {staked / 10**SJOE_JOE['decimals']:,.6f} JOE; "
3002
+ f"clamping unstake to that.")
3003
+ amount_wei = staked
3004
+ pending = account.functions.rewardsInSJoe().call()
3005
+
3006
+ print(f"sJOE unstake: {amount_wei / 10**SJOE_JOE['decimals']:,.6f} JOE from Prime Account {pa}")
3007
+ print(f" Staked JOE: {staked / 10**SJOE_JOE['decimals']:,.6f}")
3008
+ print(f" Calls unstakeJoe({amount_wei}) on the Prime Account.")
3009
+ print(f" Pending USDC auto-claimed on unstake: {pending / 10**SJOE_REWARD['decimals']:,.6f} "
3010
+ f"(net of the {SJOE_CLAIMING_FEE_PCT:.0f}% claim fee).")
3011
+ print(" unstakeJoe is NOT solvency-gated — no RedStone payload needed.")
3012
+
3013
+ if not execute:
3014
+ print("Run with --execute to broadcast.")
3015
+ return
3016
+
3017
+ tx = account.functions.unstakeJoe(amount_wei).build_transaction({
3018
+ "from": acct.address, "nonce": w3.eth.get_transaction_count(acct.address),
3019
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3020
+ })
3021
+ signed = acct.sign_transaction(tx)
3022
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
3023
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
3024
+ ok = receipt["status"] == 1
3025
+ print(f"{'✓' if ok else '✗'} sJOE unstake {'confirmed' if ok else 'failed'}")
3026
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3027
+
3028
+ def cmd_sjoe_claim(execute: bool = False):
3029
+ """Claim accrued USDC rewards from sJOE into the Prime Account (claimSJoeRewards, via the
3030
+ sJOE withdraw(0) path). Carries remainsSolvent, so --execute appends a fresh RedStone price
3031
+ payload. The account receives the pending USDC minus the 10% claim fee. Preview by default."""
3032
+ w3 = get_w3()
3033
+ acct = get_account()
3034
+ pa = get_prime_account(w3, acct.address)
3035
+ if not pa:
3036
+ print("No Prime Account exists for this wallet — nothing to claim.")
3037
+ return
3038
+ pa_cs = Web3.to_checksum_address(pa)
3039
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
3040
+
3041
+ pending = account.functions.rewardsInSJoe().call()
3042
+ net = pending * (1 - SJOE_CLAIMING_FEE_PCT / 100)
3043
+ print(f"sJOE claim rewards on Prime Account {pa}")
3044
+ print(f" Pending rewards: {pending / 10**SJOE_REWARD['decimals']:,.6f} USDC")
3045
+ print(f" Net after the {SJOE_CLAIMING_FEE_PCT:.0f}% claim fee: "
3046
+ f"{net / 10**SJOE_REWARD['decimals']:,.6f} USDC")
3047
+ if pending == 0:
3048
+ print(" No pending rewards to claim.")
3049
+ return
3050
+ print(" Calls claimSJoeRewards() on the Prime Account.")
3051
+ print(" Carries remainsSolvent — appends a fresh RedStone payload on --execute.")
3052
+
3053
+ if not execute:
3054
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
3055
+ return
3056
+
3057
+ # remainsSolvent gating: cover the account's assets + debts plus USDC (the reward asset the
3058
+ # facet adds on claim). Fetched fresh — valid ~3 minutes.
3059
+ feeds = prime_account_price_feeds(account)
3060
+ if SJOE_REWARD["symbol"] not in feeds:
3061
+ feeds.append(SJOE_REWARD["symbol"])
3062
+ payload = build_redstone_payload(feeds)
3063
+ data = account.encode_abi("claimSJoeRewards", args=[]) + payload.hex()
3064
+ tx = {
3065
+ "from": acct.address, "to": pa_cs, "data": data,
3066
+ "nonce": w3.eth.get_transaction_count(acct.address),
3067
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3068
+ }
3069
+ signed = acct.sign_transaction(tx)
3070
+ tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
3071
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
3072
+ ok = receipt["status"] == 1
3073
+ print(f"{'✓' if ok else '✗'} sJOE claim {'confirmed' if ok else 'failed'}")
3074
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3075
+
3076
+ # ─── PRIME-token leverage tiers (PrimeLeverageFacet) ─────────────────────────
3077
+
3078
+ def _prime_token_contract(w3):
3079
+ """Minimal PRIME ERC20 (balanceOf + approve) for the deposit/balance reads."""
3080
+ abi = json.loads('[{"constant":true,"inputs":[{"name":"a","type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},'
3081
+ '{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"type":"function"}]')
3082
+ return w3.eth.contract(address=Web3.to_checksum_address(PRIME_TOKEN["addr"]), abi=abi)
3083
+
3084
+ def gather_prime_tier(w3, acct, account):
3085
+ """Read-only PRIME leverage-tier status. getLeverageTierFullInfo (tier, staked PRIME,
3086
+ recorded PRIME debt) + the EOA and in-account PRIME balances — all oracle-free. `account`
3087
+ may be None when no Prime Account exists yet (then only the wallet PRIME balance is read,
3088
+ tier defaults to BASIC). shouldLiquidatePrimeDebt is intentionally NOT included here (it
3089
+ mutates and is RedStone-gated; cmd_prime_tier reads it separately). Shared by
3090
+ cmd_prime_tier (print) and cmd_defi (--json). PRIME (18-dec) is a normalised float."""
3091
+ dec = 10**PRIME_TOKEN["decimals"]
3092
+ eoa_prime = _prime_token_contract(w3).functions.balanceOf(acct.address).call()
3093
+ out = {"wallet_prime": eoa_prime / dec, "tier": "BASIC", "tier_code": 0,
3094
+ "staked": 0.0, "in_account": 0.0, "recorded_debt": 0.0}
3095
+ if account is None:
3096
+ return out
3097
+ tier, staked, recorded_debt = account.functions.getLeverageTierFullInfo().call()
3098
+ in_acct_prime = account.functions.getBalance(asset_b32(PRIME_TOKEN["symbol"])).call()
3099
+ out.update({"tier_code": tier, "tier": PRIME_TIER_NAMES.get(tier, str(tier)),
3100
+ "staked": staked / dec, "in_account": in_acct_prime / dec,
3101
+ "recorded_debt": recorded_debt / dec})
3102
+ return out
3103
+
3104
+ def cmd_prime_tier():
3105
+ """Read-only: the Prime Account's PRIME leverage-tier status. getLeverageTierFullInfo
3106
+ (current tier, staked PRIME, recorded PRIME debt), getPrimeStakedAmount, the EOA + in-account
3107
+ PRIME balances, and shouldLiquidatePrimeDebt() via eth_call. All five getters are oracle-free;
3108
+ shouldLiquidatePrimeDebt is a state-mutating fn (it snapshots debt), so we only simulate it with
3109
+ eth_call — never broadcast. recordedDebt is the last on-chain snapshot; accrual since then is not
3110
+ reflected until updatePrimeDebt/a write runs (getCurrentPrimeDebt is internal, not callable)."""
3111
+ w3 = get_w3()
3112
+ acct = get_account()
3113
+ pa = get_prime_account(w3, acct.address)
3114
+ print(f"Owner wallet: {acct.address}")
3115
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI) if pa else None
3116
+ t = gather_prime_tier(w3, acct, account)
3117
+ print(f" Wallet PRIME: {t['wallet_prime']:,.6f}")
3118
+ if not pa:
3119
+ print("No Prime Account yet — no leverage tier. (Tier defaults to BASIC once created.)")
3120
+ return
3121
+ print(f"Prime Account: {pa}")
3122
+ print(f" Tier: {t['tier']} (BASIC ~5x, PREMIUM 10x)")
3123
+ print(f" Staked PRIME: {t['staked']:,.6f}")
3124
+ print(f" In-account PRIME: {t['in_account']:,.6f}")
3125
+ print(f" Recorded PRIME debt: {t['recorded_debt']:,.6f} "
3126
+ "(last snapshot; accrual since not included)")
3127
+ # shouldLiquidatePrimeDebt MUTATES (snapshots debt) -> simulate read-only with eth_call, never
3128
+ # broadcast. It reads _getDebt() internally, so despite being a PRIME-side check it hits the
3129
+ # solvency oracle path and reverts 0xe7764c9e on a bare call — a RedStone payload must be
3130
+ # appended, same as the solvency views in prime-summary. (The other four PRIME getters above
3131
+ # ARE oracle-free; only this one touches debt.) Falls back gracefully if the gateway is down.
3132
+ try:
3133
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3134
+ flag = redstone_view_call(w3, account, "shouldLiquidatePrimeDebt", payload)[0]
3135
+ print(f" PRIME-debt liquidatable: {'YES — staked PRIME no longer covers debt' if flag else 'no'}")
3136
+ except Exception as e:
3137
+ print(f" PRIME-debt liquidatable: RedStone fetch/call failed ({type(e).__name__})")
3138
+
3139
+ def cmd_prime_needed(borrow_usd: float, tier: str = "premium"):
3140
+ """Read-only quote: PRIME needed to back a given USD borrow at the chosen tier. Calls
3141
+ getRequiredPrimeStake(tier, int(borrow_usd * 1e18)) on the facet — it reads the LIVE
3142
+ tieredPrimeStakingRatio from the TokenManager (governance-mutable), so this is the
3143
+ authoritative figure, not a hard-coded ratio. Oracle-free, no tx."""
3144
+ if tier not in PRIME_TIERS:
3145
+ print(f"Unknown --tier '{tier}'. Choose from: {', '.join(PRIME_TIERS)}")
3146
+ return
3147
+ w3 = get_w3()
3148
+ # The view is oracle-free and state-independent, so any deployed Prime Account works as the
3149
+ # call target. Fall back to the facet address itself if the wallet has no account yet.
3150
+ acct = get_account()
3151
+ pa = get_prime_account(w3, acct.address)
3152
+ target = Web3.to_checksum_address(pa) if pa else Web3.to_checksum_address(PRIME_LEVERAGE_FACET)
3153
+ account = w3.eth.contract(address=target, abi=PRIME_ACCOUNT_ABI)
3154
+ borrowed_value = int(borrow_usd * 1e18)
3155
+ required = account.functions.getRequiredPrimeStake(PRIME_TIERS[tier], borrowed_value).call()
3156
+ print(f"To back a ${borrow_usd:,.2f} borrow at {tier.upper()} tier:")
3157
+ print(f" PRIME required: {required / 10**PRIME_TOKEN['decimals']:,.6f} PRIME")
3158
+ print(" (live tieredPrimeStakingRatio from TokenManager — proportional to USD borrow)")
3159
+
3160
+ def cmd_prime_activate(amount: float = None, execute: bool = False):
3161
+ """Activate PREMIUM (10x) tier. The on-chain flow (verified PrimeLeverageFacet source):
3162
+ stakePrimeAndActivatePremium() stakes getRequiredPrimeStake(PREMIUM, (totalValue-debt)*10)
3163
+ from the account's IN-ACCOUNT PRIME balance, then sets tier=PREMIUM. So PRIME must already
3164
+ sit inside the account. --amount N first runs depositPrime(N) to move PRIME from the EOA in
3165
+ (ERC20 approve -> depositPrime, which is remainsSolvent-gated so a RedStone payload is appended
3166
+ on --execute); omit --amount to stake from PRIME already in the account. Preview prints the plan
3167
+ and the projected required stake; fails closed if the in-account PRIME would be short."""
3168
+ w3 = get_w3()
3169
+ acct = get_account()
3170
+ pa = get_prime_account(w3, acct.address)
3171
+ if not pa:
3172
+ print("No Prime Account exists for this wallet — create and fund one first:")
3173
+ print(" deltaprime create-prime-account --execute")
3174
+ return
3175
+ pa_cs = Web3.to_checksum_address(pa)
3176
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
3177
+ prime = _prime_token_contract(w3)
3178
+
3179
+ tier = account.functions.getLeverageTier().call()
3180
+ if tier == PRIME_TIERS["premium"]:
3181
+ print(f"Prime Account {pa} is already in PREMIUM tier — nothing to do.")
3182
+ return
3183
+
3184
+ deposit_wei = int(amount * 10**PRIME_TOKEN["decimals"]) if amount else 0
3185
+ eoa_prime = prime.functions.balanceOf(acct.address).call()
3186
+ in_acct_prime = account.functions.getBalance(asset_b32(PRIME_TOKEN["symbol"])).call()
3187
+ # depositPrime caps to the EOA balance on-chain; mirror that for an honest preview.
3188
+ deposit_wei = min(deposit_wei, eoa_prime) if deposit_wei else 0
3189
+ projected_in_acct = in_acct_prime + deposit_wei
3190
+
3191
+ print(f"PRIME activate PREMIUM tier on Prime Account {pa}")
3192
+ print(f" Wallet PRIME: {eoa_prime / 10**PRIME_TOKEN['decimals']:,.6f}")
3193
+ print(f" In-account PRIME: {in_acct_prime / 10**PRIME_TOKEN['decimals']:,.6f}")
3194
+
3195
+ # Projected required stake = getRequiredPrimeStake(PREMIUM, (totalValue - debt) * 10). totalValue
3196
+ # and debt are RedStone-gated solvency views — fetch best-effort like prime-summary does.
3197
+ required = None
3198
+ try:
3199
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3200
+ total_value = redstone_view_call(w3, account, "getTotalValue", payload)[0]
3201
+ debt = redstone_view_call(w3, account, "getDebt", payload)[0]
3202
+ free_collateral = total_value - debt if total_value > debt else 0
3203
+ required = account.functions.getRequiredPrimeStake(
3204
+ PRIME_TIERS["premium"], free_collateral * 10).call()
3205
+ print(f" Free collateral: ${free_collateral / 1e18:,.2f} "
3206
+ f"-> stakes against 10x = ${free_collateral * 10 / 1e18:,.2f} max debt")
3207
+ print(f" Required stake: {required / 10**PRIME_TOKEN['decimals']:,.6f} PRIME")
3208
+ except Exception as e:
3209
+ print(f" Required stake: could not compute (RedStone fetch/call failed: {type(e).__name__})")
3210
+
3211
+ if deposit_wei:
3212
+ print(f" Step 1: approve + depositPrime({deposit_wei}) "
3213
+ f"({deposit_wei / 10**PRIME_TOKEN['decimals']:,.6f} PRIME from wallet, RedStone-gated)")
3214
+ print(f" Step 2: stakePrimeAndActivatePremium()")
3215
+ else:
3216
+ print(f" Step 1: stakePrimeAndActivatePremium() (stakes from in-account PRIME; no deposit)")
3217
+
3218
+ if required is not None and projected_in_acct < required:
3219
+ print(f" ✗ Projected in-account PRIME "
3220
+ f"({projected_in_acct / 10**PRIME_TOKEN['decimals']:,.6f}) is below the required stake "
3221
+ f"({required / 10**PRIME_TOKEN['decimals']:,.6f}). stakePrimeAndActivatePremium would "
3222
+ "revert 'Insufficient PRIME balance'.")
3223
+ print(f" Deposit more PRIME first: deltaprime prime-activate --amount <N> --execute")
3224
+ return
3225
+
3226
+ if not execute:
3227
+ print("Run with --execute to broadcast"
3228
+ + (" (depositPrime appends a fresh RedStone price payload)." if deposit_wei else "."))
3229
+ return
3230
+
3231
+ if deposit_wei:
3232
+ # Build the RedStone payload FIRST (PRIME has no RedStone feed — only the account's
3233
+ # collateral assets are priced for remainsSolvent) so a gateway failure broadcasts nothing.
3234
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3235
+ nonce = w3.eth.get_transaction_count(acct.address)
3236
+ app_tx = prime.functions.approve(pa_cs, deposit_wei).build_transaction({
3237
+ "from": acct.address, "nonce": nonce,
3238
+ "gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3239
+ })
3240
+ w3.eth.send_raw_transaction(acct.sign_transaction(app_tx).raw_transaction)
3241
+ data = account.encode_abi("depositPrime", args=[deposit_wei]) + payload.hex()
3242
+ dep_tx = {
3243
+ "from": acct.address, "to": pa_cs, "data": data,
3244
+ "nonce": nonce + 1,
3245
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3246
+ }
3247
+ dep_hash = w3.eth.send_raw_transaction(acct.sign_transaction(dep_tx).raw_transaction)
3248
+ dep_ok = w3.eth.wait_for_transaction_receipt(dep_hash, timeout=180)["status"] == 1
3249
+ print(f"{'✓' if dep_ok else '✗'} depositPrime {'confirmed' if dep_ok else 'failed'}")
3250
+ print(f" Tx: {EXPLORER}/tx/{dep_hash.hex()}")
3251
+ if not dep_ok:
3252
+ print(" Aborting — not activating PREMIUM after a failed deposit.")
3253
+ return
3254
+
3255
+ # stakePrimeAndActivatePremium ALSO requires a RedStone payload appended — a bare call reverts
3256
+ # CalldataMustHaveValidPayload / 0xe7764c9e (verified read-only 24-05-2026). This is the on-chain
3257
+ # equivalent of the frontend "UNLOCK 10X" button.
3258
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3259
+ data = account.encode_abi("stakePrimeAndActivatePremium", args=[]) + payload.hex()
3260
+ tx = {
3261
+ "from": acct.address, "to": pa_cs, "data": data,
3262
+ "nonce": w3.eth.get_transaction_count(acct.address),
3263
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3264
+ }
3265
+ tx_hash = w3.eth.send_raw_transaction(acct.sign_transaction(tx).raw_transaction)
3266
+ ok = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)["status"] == 1
3267
+ print(f"{'✓' if ok else '✗'} PREMIUM tier {'activated' if ok else 'activation failed'}")
3268
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3269
+
3270
+ def cmd_prime_deposit(amount: float, execute: bool = False):
3271
+ """Deposit PRIME from the wallet (EOA) INTO the Prime Account, without activating PREMIUM.
3272
+ ERC20 approve -> depositPrime(amount); depositPrime is remainsSolvent-gated, so a fresh
3273
+ RedStone payload is appended on --execute. The PRIME then sits in-account (ready for
3274
+ prime-activate). Caps to the wallet's PRIME balance. Approve (nonce N) and depositPrime
3275
+ (nonce N+1) are sent as a sequential pair so the allowance is in place before the move."""
3276
+ w3 = get_w3()
3277
+ acct = get_account()
3278
+ pa = get_prime_account(w3, acct.address)
3279
+ if not pa:
3280
+ print("No Prime Account exists for this wallet — create one first:")
3281
+ print(" deltaprime create-prime-account --execute")
3282
+ return
3283
+ pa_cs = Web3.to_checksum_address(pa)
3284
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
3285
+ prime = _prime_token_contract(w3)
3286
+
3287
+ eoa_prime = prime.functions.balanceOf(acct.address).call()
3288
+ in_acct_prime = account.functions.getBalance(asset_b32(PRIME_TOKEN["symbol"])).call()
3289
+ deposit_wei = int(amount * 10**PRIME_TOKEN["decimals"])
3290
+ deposit_wei = min(deposit_wei, eoa_prime) # depositPrime caps to the EOA balance on-chain
3291
+
3292
+ print(f"PRIME deposit into Prime Account {pa}")
3293
+ print(f" Wallet PRIME: {eoa_prime / 10**PRIME_TOKEN['decimals']:,.6f}")
3294
+ print(f" In-account PRIME: {in_acct_prime / 10**PRIME_TOKEN['decimals']:,.6f}")
3295
+ if deposit_wei <= 0:
3296
+ print(" Nothing to deposit (wallet PRIME is 0).")
3297
+ return
3298
+ print(f" Deposit: {deposit_wei / 10**PRIME_TOKEN['decimals']:,.6f} PRIME "
3299
+ "(approve + depositPrime, RedStone-gated)")
3300
+ print(f" Resulting in-account PRIME: "
3301
+ f"{(in_acct_prime + deposit_wei) / 10**PRIME_TOKEN['decimals']:,.6f}")
3302
+
3303
+ if not execute:
3304
+ print("Run with --execute to broadcast (depositPrime appends a fresh RedStone price payload).")
3305
+ return
3306
+
3307
+ # Build the RedStone payload FIRST (PRIME itself has no RedStone feed — only the account's
3308
+ # collateral assets are priced for remainsSolvent) so a gateway failure broadcasts nothing.
3309
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3310
+ nonce = w3.eth.get_transaction_count(acct.address)
3311
+ app_tx = prime.functions.approve(pa_cs, deposit_wei).build_transaction({
3312
+ "from": acct.address, "nonce": nonce,
3313
+ "gas": 100000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3314
+ })
3315
+ w3.eth.send_raw_transaction(acct.sign_transaction(app_tx).raw_transaction)
3316
+ data = account.encode_abi("depositPrime", args=[deposit_wei]) + payload.hex()
3317
+ dep_tx = {
3318
+ "from": acct.address, "to": pa_cs, "data": data,
3319
+ "nonce": nonce + 1,
3320
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3321
+ }
3322
+ dep_hash = w3.eth.send_raw_transaction(acct.sign_transaction(dep_tx).raw_transaction)
3323
+ dep_ok = w3.eth.wait_for_transaction_receipt(dep_hash, timeout=180)["status"] == 1
3324
+ print(f"{'✓' if dep_ok else '✗'} depositPrime {'confirmed' if dep_ok else 'failed'}")
3325
+ print(f" Tx: {EXPLORER}/tx/{dep_hash.hex()}")
3326
+
3327
+ def cmd_prime_deactivate(withdraw: bool = False, execute: bool = False):
3328
+ """Drop back to BASIC tier (deactivatePremiumTier(withdrawStake)). The facet REPAYS ALL PRIME
3329
+ debt first and reverts if the in-account PRIME can't cover it (50% of the repaid PRIME is burned,
3330
+ 50% to treasury). --withdraw maps to withdrawStake=true, which also releases staked PRIME above
3331
+ the new BASIC requirement (which is 0, so all of it) back into the account. onlyOwner, NOT
3332
+ solvency-gated — no RedStone payload. Preview by default."""
3333
+ w3 = get_w3()
3334
+ acct = get_account()
3335
+ pa = get_prime_account(w3, acct.address)
3336
+ if not pa:
3337
+ print("No Prime Account exists for this wallet — nothing to deactivate.")
3338
+ return
3339
+ pa_cs = Web3.to_checksum_address(pa)
3340
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
3341
+
3342
+ tier, staked, recorded_debt = account.functions.getLeverageTierFullInfo().call()
3343
+ if tier == PRIME_TIERS["basic"]:
3344
+ print(f"Prime Account {pa} is already in BASIC tier — nothing to deactivate.")
3345
+ return
3346
+ in_acct_prime = account.functions.getBalance(asset_b32(PRIME_TOKEN["symbol"])).call()
3347
+
3348
+ print(f"PRIME deactivate PREMIUM -> BASIC on Prime Account {pa}")
3349
+ print(f" Recorded PRIME debt: {recorded_debt / 10**PRIME_TOKEN['decimals']:,.6f} "
3350
+ "(facet repays the FULL current debt, incl. accrual, before downgrading)")
3351
+ print(f" In-account PRIME: {in_acct_prime / 10**PRIME_TOKEN['decimals']:,.6f} (must cover the debt)")
3352
+ print(f" Staked PRIME: {staked / 10**PRIME_TOKEN['decimals']:,.6f}")
3353
+ print(f" Calls deactivatePremiumTier(withdrawStake={str(withdraw).lower()}).")
3354
+ print(" Repays all PRIME debt first (50% burn / 50% treasury); reverts if PRIME can't cover it.")
3355
+ if withdraw:
3356
+ print(" --withdraw: releases excess staked PRIME (BASIC requires 0) back into the account.")
3357
+ else:
3358
+ print(" Without --withdraw: stake stays put; release it later with prime-unstake.")
3359
+
3360
+ if not execute:
3361
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
3362
+ return
3363
+
3364
+ # PrimeLeverageFacet requires a RedStone payload appended (its sibling repayPrimeDebt reverts
3365
+ # CalldataMustHaveValidPayload / 0xe7764c9e without one — verified read-only 24-05-2026; deactivate
3366
+ # repays the PRIME debt internally, so it needs the same price context).
3367
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3368
+ data = account.encode_abi("deactivatePremiumTier", args=[withdraw]) + payload.hex()
3369
+ tx = {
3370
+ "from": acct.address, "to": account.address, "data": data,
3371
+ "nonce": w3.eth.get_transaction_count(acct.address),
3372
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3373
+ }
3374
+ tx_hash = w3.eth.send_raw_transaction(acct.sign_transaction(tx).raw_transaction)
3375
+ ok = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)["status"] == 1
3376
+ print(f"{'✓' if ok else '✗'} PREMIUM tier {'deactivated' if ok else 'deactivation failed'}")
3377
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3378
+
3379
+ def cmd_prime_unstake(amount: float, execute: bool = False):
3380
+ """Unstake PRIME from the leverage stake back into the account (unstakePrime). onlyOwner, NOT
3381
+ solvency-gated — no RedStone payload. The facet guards (when still PREMIUM): the remaining stake
3382
+ must cover BOTH the PREMIUM USD ratio against current debt AND the accrued PRIME debt — it
3383
+ snapshots debt first, so a short unstake reverts. Caps the request to the staked balance.
3384
+ Preview by default."""
3385
+ w3 = get_w3()
3386
+ acct = get_account()
3387
+ pa = get_prime_account(w3, acct.address)
3388
+ if not pa:
3389
+ print("No Prime Account exists for this wallet — nothing to unstake.")
3390
+ return
3391
+ pa_cs = Web3.to_checksum_address(pa)
3392
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
3393
+
3394
+ staked = account.functions.getPrimeStakedAmount().call()
3395
+ if staked == 0:
3396
+ print(f"Prime Account {pa} has no staked PRIME — nothing to unstake.")
3397
+ return
3398
+ amount_wei = int(amount * 10**PRIME_TOKEN["decimals"])
3399
+ if amount_wei > staked:
3400
+ print(f"Staked PRIME is {staked / 10**PRIME_TOKEN['decimals']:,.6f}; clamping unstake to that.")
3401
+ amount_wei = staked
3402
+
3403
+ print(f"PRIME unstake: {amount_wei / 10**PRIME_TOKEN['decimals']:,.6f} PRIME from Prime Account {pa}")
3404
+ print(f" Staked PRIME: {staked / 10**PRIME_TOKEN['decimals']:,.6f}")
3405
+ print(f" Calls unstakePrime({amount_wei}).")
3406
+ print(" In PREMIUM tier the remaining stake must still cover the USD ratio + accrued PRIME debt,")
3407
+ print(" else the facet reverts. Appends a RedStone price payload (the facet requires one).")
3408
+
3409
+ if not execute:
3410
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
3411
+ return
3412
+
3413
+ # PrimeLeverageFacet requires a RedStone payload appended (sibling repayPrimeDebt confirmed via
3414
+ # probe 24-05-2026; unstakePrime checks the USD ratio + accrued PRIME debt, so it needs price context).
3415
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3416
+ data = account.encode_abi("unstakePrime", args=[amount_wei]) + payload.hex()
3417
+ tx = {
3418
+ "from": acct.address, "to": account.address, "data": data,
3419
+ "nonce": w3.eth.get_transaction_count(acct.address),
3420
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3421
+ }
3422
+ tx_hash = w3.eth.send_raw_transaction(acct.sign_transaction(tx).raw_transaction)
3423
+ ok = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)["status"] == 1
3424
+ print(f"{'✓' if ok else '✗'} PRIME unstake {'confirmed' if ok else 'failed'}")
3425
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3426
+
3427
+ def cmd_prime_repay(amount: float, execute: bool = False):
3428
+ """Repay accrued PRIME rent-debt (repayPrimeDebt) using in-account PRIME. onlyOwner, NOT
3429
+ solvency-gated — no RedStone payload. The facet snapshots debt, caps the amount to the current
3430
+ debt (no overpayment), and splits the repaid PRIME 50% burn / 50% treasury. Preview by default.
3431
+ recordedDebt shown is the last snapshot; the on-chain repay re-snapshots, so the true current
3432
+ debt may be slightly higher (unsnapshotted accrual)."""
3433
+ w3 = get_w3()
3434
+ acct = get_account()
3435
+ pa = get_prime_account(w3, acct.address)
3436
+ if not pa:
3437
+ print("No Prime Account exists for this wallet — nothing to repay.")
3438
+ return
3439
+ pa_cs = Web3.to_checksum_address(pa)
3440
+ account = w3.eth.contract(address=pa_cs, abi=PRIME_ACCOUNT_ABI)
3441
+
3442
+ _tier, _staked, recorded_debt = account.functions.getLeverageTierFullInfo().call()
3443
+ in_acct_prime = account.functions.getBalance(asset_b32(PRIME_TOKEN["symbol"])).call()
3444
+ amount_wei = int(amount * 10**PRIME_TOKEN["decimals"])
3445
+
3446
+ print(f"PRIME repay debt: {amount} PRIME on Prime Account {pa}")
3447
+ print(f" Recorded PRIME debt: {recorded_debt / 10**PRIME_TOKEN['decimals']:,.6f} "
3448
+ "(last snapshot; repay re-snapshots and caps to the true current debt)")
3449
+ print(f" In-account PRIME: {in_acct_prime / 10**PRIME_TOKEN['decimals']:,.6f}")
3450
+ if amount_wei > in_acct_prime:
3451
+ print(f" ✗ Requested {amount} PRIME exceeds in-account PRIME "
3452
+ "(facet reverts 'Not enough PRIME to repay the debt'). Refusing.")
3453
+ print(" Deposit PRIME into the account first: deltaprime prime-activate --amount <N> --execute")
3454
+ return
3455
+ print(f" Calls repayPrimeDebt({amount_wei}) — caps to current debt, 50% burn / 50% treasury.")
3456
+ print(" Appends a RedStone price payload (the facet requires one — verified read-only 24-05-2026).")
3457
+
3458
+ if not execute:
3459
+ print("Run with --execute to broadcast (appends a fresh RedStone price payload).")
3460
+ return
3461
+
3462
+ # The PrimeLeverageFacet requires a RedStone payload appended, exactly like lb-remove:
3463
+ # a bare call reverts CalldataMustHaveValidPayload / 0xe7764c9e (verified read-only 24-05-2026).
3464
+ payload = build_redstone_payload(prime_account_price_feeds(account))
3465
+ base_calldata = account.encode_abi("repayPrimeDebt", args=[amount_wei])
3466
+ tx = {
3467
+ "from": acct.address, "to": pa_cs, "data": base_calldata + payload.hex(),
3468
+ "nonce": w3.eth.get_transaction_count(acct.address),
3469
+ "gas": 3000000, "gasPrice": _tx_gas_price(w3), "chainId": CHAIN_ID,
3470
+ }
3471
+ tx_hash = w3.eth.send_raw_transaction(acct.sign_transaction(tx).raw_transaction)
3472
+ ok = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)["status"] == 1
3473
+ print(f"{'✓' if ok else '✗'} PRIME debt repay {'confirmed' if ok else 'failed'}")
3474
+ print(f" Tx: {EXPLORER}/tx/{tx_hash.hex()}")
3475
+
3476
+ # ─── Zaps (tool-level macros) ────────────────────────────────────────────────
3477
+ # DeltaPrime zaps are NOT a separate on-chain facet (capabilities §7) — they are front-end
3478
+ # orchestration that chains the existing primitives into a one-click leveraged entry. We
3479
+ # replicate that as a macro over the existing in-tool leg commands (cmd_fund / cmd_borrow /
3480
+ # cmd_swap / cmd_gmx_deposit), reusing their on-chain encoding verbatim — no new ABI.
3481
+ #
3482
+ # Design: ONE clean, bounded "leveraged long" flow (the canonical DeltaPrime first-zap),
3483
+ # terminating in a GMX V2 GM market deposit. Legs, in order:
3484
+ # 1. fund — move volatile collateral from the EOA into the Prime Account.
3485
+ # 2. borrow — borrow USDC against it (the leverage).
3486
+ # 3. swap — OPTIONAL (--swap): YieldYak-swap the borrowed USDC into the market's long
3487
+ # token, to deposit the volatile (long) leg instead of the USDC (short) leg.
3488
+ # 4. gmx-deposit — deposit --deposit-amount of the chosen leg into --market.
3489
+ # Each leg is its OWN transaction with an EXPLICIT amount (no fragile auto-sizing across an
3490
+ # oracle-priced/async boundary — the brief's correctness bar). Preview prints the full ordered
3491
+ # plan; --execute runs the legs sequentially and STOPS on the first failure, reporting exactly
3492
+ # which leg succeeded and which failed (partial-state safety). The terminal GMX leg is ASYNC:
3493
+ # --execute can only FIRE the deposit request — a GMX keeper mints the GM tokens later and the
3494
+ # account is FROZEN until then (re-check `gmx-positions` once the keeper settles).
3495
+ #
3496
+ # Only the GMX terminal is built (it is the canonical leveraged-long zap and exercises the
3497
+ # async/freeze path). An LB-terminal leveraged long is reachable by running the same fund ->
3498
+ # borrow -> [swap] legs then `lb-add` manually; kept out to hold the surface small.
3499
+
3500
+ def cmd_zap(market: str, collateral_pool: str, collateral_amount: float, borrow_amount: float,
3501
+ deposit_amount: float, side: str = "short", swap_to_long: bool = False,
3502
+ slippage_pct: float = 1.0, fee_buffer: float = 2.0, execute: bool = False):
3503
+ """Leveraged-long zap: fund collateral -> borrow USDC -> [swap USDC->long] -> GMX GM deposit.
3504
+ Composes the existing leg commands; each leg is its own tx. Preview prints the ordered plan;
3505
+ --execute runs the legs in order and stops on the first failure (partial-state safe). The GMX
3506
+ leg is async (fires the request; the keeper settles later, account frozen until then)."""
3507
+ if market not in GMX_MARKETS:
3508
+ print(f"Unknown --market '{market}'. Choose from: {', '.join(GMX_MARKETS)}")
3509
+ return
3510
+ mkt = GMX_MARKETS[market]
3511
+ if mkt["plus"]:
3512
+ print(f"--market '{market}' is a single-sided GM+ market. This zap targets two-sided GM "
3513
+ f"markets (USDC short leg + volatile long leg). Choose a GM market: "
3514
+ f"{', '.join(k for k, m in GMX_MARKETS.items() if not m['plus'])}.")
3515
+ return
3516
+ if collateral_pool not in POOLS:
3517
+ print(f"Unknown --collateral '{collateral_pool}'. Choose from: {', '.join(POOLS)}")
3518
+ return
3519
+ if side not in ("long", "short"):
3520
+ print("--side must be 'long' or 'short'.")
3521
+ return
3522
+ if collateral_amount <= 0 or borrow_amount <= 0 or deposit_amount <= 0:
3523
+ print("--collateral-amount, --borrow-amount and --deposit-amount must all be > 0.")
3524
+ return
3525
+ if slippage_pct > GMX_MAX_SLIPPAGE_PCT:
3526
+ print(f"--slippage {slippage_pct}% exceeds the GMX {GMX_MAX_SLIPPAGE_PCT}% cap; the deposit "
3527
+ "leg would revert. Lower it.")
3528
+ return
3529
+
3530
+ long_sym, short_sym = mkt["long"], mkt["short"] # short is always USDC on these markets
3531
+ deposit_leg_sym = long_sym if side == "long" else short_sym
3532
+
3533
+ # Ordered leg plan. Each entry: (label, callable taking execute=bool, gated?, note).
3534
+ legs = []
3535
+ legs.append((
3536
+ f"1. fund {collateral_amount} {pool_to_asset_symbol(collateral_pool)} (collateral) into the Prime Account",
3537
+ lambda ex: cmd_fund(collateral_pool, collateral_amount, ex),
3538
+ False, "EOA wallet must hold the collateral; ERC20 approves the account."))
3539
+ legs.append((
3540
+ f"2. borrow {borrow_amount} {short_sym} against the collateral (leverage)",
3541
+ lambda ex: cmd_borrow(_SYMBOL_TO_POOL[short_sym], borrow_amount, ex),
3542
+ True, "borrow() — needs the account solvent after the draw."))
3543
+ if swap_to_long:
3544
+ legs.append((
3545
+ f"3. swap {borrow_amount} {short_sym} -> {long_sym} (YieldYak) to fund the long leg",
3546
+ lambda ex: cmd_swap(short_sym, long_sym, borrow_amount, slippage_pct, "yak", ex),
3547
+ True, "yakSwap on in-account USDC; RedStone-gated."))
3548
+ legs.append((
3549
+ f"{'4' if swap_to_long else '3'}. gmx-deposit {deposit_amount} {deposit_leg_sym} "
3550
+ f"({'long' if side == 'long' else 'short'} leg) into [{market}] {mkt['gm_feed']}",
3551
+ lambda ex: cmd_gmx_deposit(market, deposit_amount, side == "long", slippage_pct, fee_buffer, ex),
3552
+ True, "PAYABLE + ASYNC: fires a GMX deposit request, pays the keeper execution fee, "
3553
+ "FREEZES the account until the keeper callback mints the GM tokens."))
3554
+
3555
+ print(f"=== Leveraged-long zap into [{market}] {mkt['gm_feed']} (Prime Account macro) ===")
3556
+ print(f" Collateral: {collateral_amount} {pool_to_asset_symbol(collateral_pool)} | "
3557
+ f"Borrow: {borrow_amount} {short_sym} | GM deposit: {deposit_amount} {deposit_leg_sym} "
3558
+ f"({'long' if side == 'long' else 'short'} leg){' | swap USDC->'+long_sym if swap_to_long else ''}")
3559
+ print(f" {len(legs)} legs, each its own transaction. Solvency-gated legs append a RedStone "
3560
+ "payload on --execute.")
3561
+ print(" Ordered plan:")
3562
+ for label, _fn, gated, note in legs:
3563
+ print(f" {label} [{'RedStone-gated' if gated else 'not gated'}]")
3564
+ print(f" {note}")
3565
+ print(" Terminal GMX leg is ASYNC — --execute only FIRES the deposit request; a GMX keeper")
3566
+ print(" mints the GM tokens later and the account is FROZEN until then. Re-check gmx-positions")
3567
+ print(" once the keeper settles.")
3568
+
3569
+ if not execute:
3570
+ print("\n PREVIEW per leg (each shown as it would run, nothing broadcast):")
3571
+ for label, fn, _gated, _note in legs:
3572
+ print(f"\n --- {label} ---")
3573
+ fn(False)
3574
+ print("\nRun with --execute to broadcast the legs in order (stops on the first failure).")
3575
+ return
3576
+
3577
+ print("\n EXECUTING legs in order — stops immediately on any failure.\n")
3578
+ done = []
3579
+ for idx, (label, fn, _gated, _note) in enumerate(legs, 1):
3580
+ print(f" --- Running {label} ---")
3581
+ result = fn(True)
3582
+ if result is True:
3583
+ done.append(label)
3584
+ continue
3585
+ # Any non-True return (False = broadcast failed; None = pre-flight refusal/short balance)
3586
+ # stops the chain. The leg already printed why.
3587
+ print(f"\n ✗ ZAP HALTED at leg {idx}: {label}")
3588
+ print(f" Result: {'transaction failed on-chain' if result is False else 'leg refused / did not broadcast (see its output above)'}.")
3589
+ if done:
3590
+ print(f" Legs that DID complete: {len(done)}")
3591
+ for d in done:
3592
+ print(f" ✓ {d}")
3593
+ print(" The Prime Account is now in a PARTIAL state — the completed legs are live")
3594
+ print(" on-chain. Review with prime-summary before retrying; do NOT blindly re-run")
3595
+ print(" the whole zap (it would repeat the completed legs).")
3596
+ else:
3597
+ print(" No legs completed; nothing changed on-chain.")
3598
+ return
3599
+
3600
+ print(f"\n ✓ ZAP COMPLETE — all {len(legs)} legs fired.")
3601
+ print(" NOTE: the final GMX deposit is ASYNC. The GM tokens are not minted yet — a GMX")
3602
+ print(" keeper settles the request in a later block and the account stays FROZEN until")
3603
+ print(f" then. Check `deltaprime gmx-positions` for the {mkt['gm_feed']} balance once it settles.")
3604
+
3605
+ def gather_defi() -> dict:
3606
+ """Aggregate ALL DeltaPrime positions for the selected wallet into one DeBank-style dict.
3607
+ Read-only: reuses the gather_* helpers (lending/solvency, GMX V2 LP, TraderJoe V2 LB, sJOE,
3608
+ PRIME tier), each of which only does eth_calls. Empty groups are omitted. total_usd /
3609
+ health_ratio / solvent come from the RedStone-gated solvency views; per-asset USD is
3610
+ best-effort (null where a RedStone feed is missing). Never broadcasts."""
3611
+ w3 = get_w3()
3612
+ acct = get_account()
3613
+ pa = get_prime_account(w3, acct.address)
3614
+ result = {
3615
+ "protocol": "DeltaPrime", "url": "https://app.deltaprime.io", "chain": "avalanche",
3616
+ "wallet": acct.address, "prime_account": pa,
3617
+ "total_usd": None, "health_ratio": None, "solvent": None,
3618
+ "groups": [], "status": "ok",
3619
+ }
3620
+ account = w3.eth.contract(address=Web3.to_checksum_address(pa), abi=PRIME_ACCOUNT_ABI) if pa else None
3621
+
3622
+ # PRIME tier reads the EOA balance even with no Prime Account, so always gather it.
3623
+ tier = gather_prime_tier(w3, acct, account)
3624
+
3625
+ if account is not None:
3626
+ lending = gather_lending(w3, account)
3627
+ result["total_usd"] = lending["total_value_usd"]
3628
+ result["health_ratio"] = lending["health_ratio"]
3629
+ result["solvent"] = lending["solvent"]
3630
+ if lending["supplied"] or lending["borrowed"]:
3631
+ result["groups"].append({
3632
+ "type": "Lending / Leverage", "health_ratio": lending["health_ratio"],
3633
+ "supplied": [{"symbol": r["symbol"], "balance": r["balance"], "usd": r.get("usd")}
3634
+ for r in lending["supplied"]],
3635
+ "borrowed": [{"symbol": r["symbol"], "balance": r["balance"], "usd": r.get("usd")}
3636
+ for r in lending["borrowed"]],
3637
+ })
3638
+
3639
+ gmx = gather_gmx(w3, account)
3640
+ if gmx:
3641
+ result["groups"].append({"type": "GMX V2 LP", "items": [
3642
+ {"label": p["gm_feed"], "balance": p["balance"], "symbol": p["kind"], "usd": p.get("usd")}
3643
+ for p in gmx]})
3644
+
3645
+ lb = gather_lb(w3, account)
3646
+ lb_items = []
3647
+ for p in lb:
3648
+ if p.get("error"):
3649
+ continue
3650
+ lb_items.append({"label": p["label"], "active_bin": p["active_bin"], "bins": p["bins"],
3651
+ "token_x": {"symbol": p["token_x"]["symbol"], "balance": f"{p['token_x']['amount']:.6f}"},
3652
+ "token_y": {"symbol": p["token_y"]["symbol"], "balance": f"{p['token_y']['amount']:.6f}"}})
3653
+ if lb_items:
3654
+ result["groups"].append({"type": "TraderJoe V2 LB", "items": lb_items})
3655
+
3656
+ sjoe = gather_sjoe(account)
3657
+ if sjoe["staked_raw"] > 0 or sjoe["pending_raw"] > 0:
3658
+ grp = {"type": "sJOE Staking",
3659
+ "items": [{"symbol": sjoe["joe_symbol"], "balance": sjoe["staked"], "usd": None}]}
3660
+ if sjoe["pending_raw"] > 0:
3661
+ grp["rewards"] = [{"symbol": sjoe["reward_symbol"], "balance": sjoe["pending"]}]
3662
+ result["groups"].append(grp)
3663
+
3664
+ # PRIME group: include whenever there is any PRIME stake or in-account balance (the
3665
+ # in-account ~200 PRIME with an otherwise empty account is the expected current state).
3666
+ if tier["staked"] > 0 or tier["in_account"] > 0:
3667
+ result["groups"].append({
3668
+ "type": "PRIME", "tier": tier["tier"],
3669
+ "staked": tier["staked"], "in_account": tier["in_account"],
3670
+ })
3671
+ return result
3672
+
3673
+ def cmd_defi(as_json: bool = True):
3674
+ """Aggregate all DeltaPrime positions for the wallet. Default output is the DeBank-style
3675
+ JSON (the dashboard consumer). On error, emits {"status":"error", ...} rather than raising,
3676
+ so the caller always gets parseable JSON."""
3677
+ try:
3678
+ data = gather_defi()
3679
+ except Exception as e:
3680
+ data = {"protocol": "DeltaPrime", "chain": "avalanche",
3681
+ "status": "error", "error": f"{type(e).__name__}: {e}"}
3682
+ print(json.dumps(data, indent=2))
3683
+
3684
+ def main():
3685
+ try:
3686
+ _dispatch()
3687
+ except RuntimeError as e:
3688
+ print(f"deltaprime: {e}", file=sys.stderr)
3689
+ sys.exit(1)
3690
+
3691
+ def _dispatch():
3692
+ args = sys.argv[1:] if len(sys.argv) > 1 else []
3693
+ # Global signing-key override: --key <0xhex>, stripped before command dispatch.
3694
+ global _CLI_KEY
3695
+ if "--key" in args:
3696
+ i = args.index("--key")
3697
+ if i + 1 >= len(args):
3698
+ print("--key requires a hex key. Example: --key 0xabc...")
3699
+ return
3700
+ _CLI_KEY = args[i + 1]
3701
+ del args[i:i + 2]
3702
+ if not args or args[0] in ("-h", "--help"):
3703
+ print(__doc__)
3704
+ return
3705
+
3706
+ cmd = args[0]
3707
+ if cmd == "pool-info":
3708
+ pool = args[1] if len(args) > 1 else "all"
3709
+ if pool != "all" and pool not in POOLS:
3710
+ print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}, all")
3711
+ return
3712
+ cmd_pool_info(pool)
3713
+ elif cmd == "my-positions":
3714
+ cmd_my_positions()
3715
+ elif cmd == "deposit":
3716
+ pool, amount = None, None
3717
+ execute = "--execute" in args
3718
+ for i, a in enumerate(args):
3719
+ if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
3720
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3721
+ if not pool or amount is None:
3722
+ print("Usage: deltaprime deposit --pool usdc --amount 100 [--execute]")
3723
+ return
3724
+ cmd_deposit(pool, amount, execute)
3725
+ elif cmd == "withdraw":
3726
+ pool, amount = None, None
3727
+ execute = "--execute" in args
3728
+ for i, a in enumerate(args):
3729
+ if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
3730
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3731
+ if not pool or amount is None:
3732
+ print("Usage: deltaprime withdraw --pool usdc --amount 100 [--execute]")
3733
+ return
3734
+ cmd_withdraw(pool, amount, execute)
3735
+ elif cmd in ("create-prime-account", "create-account"):
3736
+ fund_pool, fund_amount = None, None
3737
+ for i, a in enumerate(args):
3738
+ if a == "--fund-pool" and i + 1 < len(args): fund_pool = args[i + 1]
3739
+ if a == "--fund-amount" and i + 1 < len(args): fund_amount = float(args[i + 1])
3740
+ if (fund_pool is None) != (fund_amount is None):
3741
+ print("Pass both --fund-pool and --fund-amount, or neither.")
3742
+ return
3743
+ if fund_pool is not None and fund_pool not in POOLS:
3744
+ print(f"Unknown pool '{fund_pool}'. Choose from: {', '.join(POOLS)}")
3745
+ return
3746
+ cmd_create_prime_account("--execute" in args, fund_pool, fund_amount)
3747
+ elif cmd == "prime-summary":
3748
+ cmd_prime_summary()
3749
+ elif cmd == "defi":
3750
+ cmd_defi("--json" in args)
3751
+ elif cmd == "fund":
3752
+ pool, amount = None, None
3753
+ execute = "--execute" in args
3754
+ for i, a in enumerate(args):
3755
+ if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
3756
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3757
+ if not pool or amount is None:
3758
+ print("Usage: deltaprime fund --pool usdc --amount 100 [--execute]")
3759
+ return
3760
+ if pool not in POOLS:
3761
+ print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
3762
+ return
3763
+ cmd_fund(pool, amount, execute)
3764
+ elif cmd in ("borrow", "repay"):
3765
+ pool, amount = None, None
3766
+ execute = "--execute" in args
3767
+ for i, a in enumerate(args):
3768
+ if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
3769
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3770
+ if not pool or amount is None:
3771
+ print(f"Usage: deltaprime {cmd} --pool usdc --amount 100 [--execute]")
3772
+ return
3773
+ if pool not in POOLS:
3774
+ print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
3775
+ return
3776
+ (cmd_borrow if cmd == "borrow" else cmd_repay)(pool, amount, execute)
3777
+ elif cmd == "swap":
3778
+ from_sym, to_sym, amount, slippage, via = None, None, None, 1.0, "yak"
3779
+ execute = "--execute" in args
3780
+ for i, a in enumerate(args):
3781
+ if a == "--from" and i + 1 < len(args): from_sym = args[i + 1]
3782
+ if a == "--to" and i + 1 < len(args): to_sym = args[i + 1]
3783
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3784
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3785
+ if a == "--via" and i + 1 < len(args): via = args[i + 1]
3786
+ if not from_sym or not to_sym or amount is None:
3787
+ print("Usage: deltaprime swap --from USDC --to AVAX --amount 10 [--via yak|paraswap] [--slippage 0.5] [--execute]")
3788
+ return
3789
+ cmd_swap(from_sym, to_sym, amount, slippage, via, execute)
3790
+ elif cmd == "swap-debt":
3791
+ from_sym, to_sym, amount, slippage = None, None, None, 1.0
3792
+ execute = "--execute" in args
3793
+ for i, a in enumerate(args):
3794
+ if a == "--from" and i + 1 < len(args): from_sym = args[i + 1]
3795
+ if a == "--to" and i + 1 < len(args): to_sym = args[i + 1]
3796
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3797
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3798
+ if not from_sym or not to_sym or amount is None:
3799
+ print("Usage: deltaprime swap-debt --from AVAX --to USDC --amount 100 [--slippage 0.5] [--execute]")
3800
+ return
3801
+ cmd_swap_debt(from_sym, to_sym, amount, slippage, execute)
3802
+ elif cmd == "withdraw-collateral":
3803
+ pool, amount = None, None
3804
+ execute = "--execute" in args
3805
+ for i, a in enumerate(args):
3806
+ if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
3807
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3808
+ if not pool or amount is None:
3809
+ print("Usage: deltaprime withdraw-collateral --pool usdc --amount 100 [--execute]")
3810
+ return
3811
+ if pool not in POOLS:
3812
+ print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
3813
+ return
3814
+ cmd_withdraw_collateral(pool, amount, execute)
3815
+ elif cmd == "withdrawal-intents":
3816
+ cmd_withdrawal_intents()
3817
+ elif cmd == "execute-withdrawal":
3818
+ pool, index = None, None
3819
+ execute = "--execute" in args
3820
+ for i, a in enumerate(args):
3821
+ if a == "--pool" and i + 1 < len(args): pool = args[i + 1]
3822
+ if a == "--index" and i + 1 < len(args): index = int(args[i + 1])
3823
+ if not pool:
3824
+ print("Usage: deltaprime execute-withdrawal --pool usdc [--index N] [--execute]")
3825
+ return
3826
+ if pool not in POOLS:
3827
+ print(f"Unknown pool '{pool}'. Choose from: {', '.join(POOLS)}")
3828
+ return
3829
+ cmd_execute_withdrawal(pool, index, execute)
3830
+ elif cmd == "gmx-positions":
3831
+ cmd_gmx_positions()
3832
+ elif cmd == "gmx-deposit":
3833
+ market, amount, side, slippage, fee_buffer = None, None, "long", 1.0, 2.0
3834
+ execute = "--execute" in args
3835
+ for i, a in enumerate(args):
3836
+ if a == "--market" and i + 1 < len(args): market = args[i + 1].lower()
3837
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3838
+ if a == "--side" and i + 1 < len(args): side = args[i + 1].lower()
3839
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3840
+ if a == "--fee-buffer" and i + 1 < len(args): fee_buffer = float(args[i + 1])
3841
+ if not market or amount is None:
3842
+ print("Usage: deltaprime gmx-deposit --market avax-usdc --amount 10 "
3843
+ "[--side long|short] [--slippage 1] [--fee-buffer 2] [--execute]")
3844
+ print(f" markets: {', '.join(GMX_MARKETS)}")
3845
+ return
3846
+ if side not in ("long", "short"):
3847
+ print("--side must be 'long' or 'short' (ignored for single-sided GM+ markets).")
3848
+ return
3849
+ cmd_gmx_deposit(market, amount, side == "long", slippage, fee_buffer, execute)
3850
+ elif cmd == "gmx-withdraw":
3851
+ market, amount, slippage, fee_buffer = None, None, 1.0, 2.0
3852
+ execute = "--execute" in args
3853
+ for i, a in enumerate(args):
3854
+ if a == "--market" and i + 1 < len(args): market = args[i + 1].lower()
3855
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3856
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3857
+ if a == "--fee-buffer" and i + 1 < len(args): fee_buffer = float(args[i + 1])
3858
+ if not market or amount is None:
3859
+ print("Usage: deltaprime gmx-withdraw --market avax-usdc --amount 5 "
3860
+ "[--slippage 1] [--fee-buffer 2] [--execute]")
3861
+ print(f" markets: {', '.join(GMX_MARKETS)}")
3862
+ return
3863
+ cmd_gmx_withdraw(market, amount, slippage, fee_buffer, execute)
3864
+ elif cmd == "lb-positions":
3865
+ cmd_lb_positions()
3866
+ elif cmd == "lb-add":
3867
+ pair, amount_x, amount_y, shape, rng, slippage, id_slip = None, 0.0, 0.0, "spot", 5, 1.0, 5
3868
+ execute = "--execute" in args
3869
+ for i, a in enumerate(args):
3870
+ if a == "--pair" and i + 1 < len(args): pair = args[i + 1].lower()
3871
+ if a == "--amount-x" and i + 1 < len(args): amount_x = float(args[i + 1])
3872
+ if a == "--amount-y" and i + 1 < len(args): amount_y = float(args[i + 1])
3873
+ if a == "--shape" and i + 1 < len(args): shape = args[i + 1].lower()
3874
+ if a == "--range" and i + 1 < len(args): rng = int(args[i + 1])
3875
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3876
+ if a == "--id-slippage" and i + 1 < len(args): id_slip = int(args[i + 1])
3877
+ if not pair or (amount_x <= 0 and amount_y <= 0):
3878
+ print("Usage: deltaprime lb-add --pair avax-usdc --amount-x N --amount-y M "
3879
+ "[--shape spot|curve|bidask] [--range 5] [--slippage 1] [--id-slippage 5] [--execute]")
3880
+ print(f" pairs: {', '.join(TJ_LB_PAIRS)}")
3881
+ return
3882
+ cmd_lb_add(pair, amount_x, amount_y, shape, rng, slippage, id_slip, execute)
3883
+ elif cmd == "lb-remove":
3884
+ pair, slippage = None, 1.0
3885
+ execute = "--execute" in args
3886
+ for i, a in enumerate(args):
3887
+ if a == "--pair" and i + 1 < len(args): pair = args[i + 1].lower()
3888
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3889
+ if not pair:
3890
+ print("Usage: deltaprime lb-remove --pair avax-usdc [--slippage 1] [--execute]")
3891
+ print(f" pairs: {', '.join(TJ_LB_PAIRS)}")
3892
+ return
3893
+ cmd_lb_remove(pair, slippage, execute)
3894
+ elif cmd == "sjoe-position":
3895
+ cmd_sjoe_position()
3896
+ elif cmd in ("sjoe-stake", "sjoe-unstake"):
3897
+ amount = None
3898
+ execute = "--execute" in args
3899
+ for i, a in enumerate(args):
3900
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3901
+ if amount is None:
3902
+ print(f"Usage: deltaprime {cmd} --amount 100 [--execute]")
3903
+ return
3904
+ (cmd_sjoe_stake if cmd == "sjoe-stake" else cmd_sjoe_unstake)(amount, execute)
3905
+ elif cmd == "sjoe-claim":
3906
+ cmd_sjoe_claim("--execute" in args)
3907
+ elif cmd == "prime-tier":
3908
+ cmd_prime_tier()
3909
+ elif cmd == "prime-needed":
3910
+ borrow, tier = None, "premium"
3911
+ for i, a in enumerate(args):
3912
+ if a == "--borrow" and i + 1 < len(args): borrow = float(args[i + 1])
3913
+ if a == "--tier" and i + 1 < len(args): tier = args[i + 1].lower()
3914
+ if borrow is None:
3915
+ print("Usage: deltaprime prime-needed --borrow 1000 [--tier premium|basic]")
3916
+ return
3917
+ cmd_prime_needed(borrow, tier)
3918
+ elif cmd == "prime-activate":
3919
+ amount = None
3920
+ execute = "--execute" in args
3921
+ for i, a in enumerate(args):
3922
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3923
+ cmd_prime_activate(amount, execute)
3924
+ elif cmd == "prime-deposit":
3925
+ amount = None
3926
+ execute = "--execute" in args
3927
+ for i, a in enumerate(args):
3928
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3929
+ if amount is None:
3930
+ print("Usage: deltaprime prime-deposit --amount 200 [--execute]")
3931
+ return
3932
+ cmd_prime_deposit(amount, execute)
3933
+ elif cmd == "prime-deactivate":
3934
+ cmd_prime_deactivate("--withdraw" in args, "--execute" in args)
3935
+ elif cmd in ("prime-unstake", "prime-repay"):
3936
+ amount = None
3937
+ execute = "--execute" in args
3938
+ for i, a in enumerate(args):
3939
+ if a == "--amount" and i + 1 < len(args): amount = float(args[i + 1])
3940
+ if amount is None:
3941
+ print(f"Usage: deltaprime {cmd} --amount 100 [--execute]")
3942
+ return
3943
+ (cmd_prime_unstake if cmd == "prime-unstake" else cmd_prime_repay)(amount, execute)
3944
+ elif cmd == "zap":
3945
+ market, collateral, side = None, None, "short"
3946
+ collateral_amount, borrow_amount, deposit_amount = None, None, None
3947
+ slippage, fee_buffer = 1.0, 2.0
3948
+ swap_to_long = "--swap" in args
3949
+ execute = "--execute" in args
3950
+ for i, a in enumerate(args):
3951
+ if a == "--market" and i + 1 < len(args): market = args[i + 1].lower()
3952
+ if a == "--collateral" and i + 1 < len(args): collateral = args[i + 1].lower()
3953
+ if a == "--collateral-amount" and i + 1 < len(args): collateral_amount = float(args[i + 1])
3954
+ if a == "--borrow-amount" and i + 1 < len(args): borrow_amount = float(args[i + 1])
3955
+ if a == "--deposit-amount" and i + 1 < len(args): deposit_amount = float(args[i + 1])
3956
+ if a == "--side" and i + 1 < len(args): side = args[i + 1].lower()
3957
+ if a == "--slippage" and i + 1 < len(args): slippage = float(args[i + 1])
3958
+ if a == "--fee-buffer" and i + 1 < len(args): fee_buffer = float(args[i + 1])
3959
+ if not market or not collateral or collateral_amount is None \
3960
+ or borrow_amount is None or deposit_amount is None:
3961
+ print("Usage: deltaprime zap --market avax-usdc --collateral wavax "
3962
+ "--collateral-amount 1 --borrow-amount 30 --deposit-amount 30 "
3963
+ "[--side long|short] [--swap] [--slippage 1] [--fee-buffer 2] [--execute]")
3964
+ print(f" GM markets: {', '.join(k for k, m in GMX_MARKETS.items() if not m['plus'])}")
3965
+ print(" Leveraged-long macro: fund collateral -> borrow USDC -> [--swap USDC->long] "
3966
+ "-> GMX GM deposit. Each leg is its own tx; --execute stops on the first failure.")
3967
+ return
3968
+ cmd_zap(market, collateral, collateral_amount, borrow_amount, deposit_amount,
3969
+ side, swap_to_long, slippage, fee_buffer, execute)
3970
+ else:
3971
+ print(f"Unknown command: {cmd}\n{__doc__}")
3972
+
3973
+ if __name__ == "__main__":
3974
+ main()