primecli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primecli/__init__.py +3 -0
- primecli/degenprime.py +1875 -0
- primecli/deltaprime.py +3974 -0
- primecli-0.1.0.dist-info/METADATA +226 -0
- primecli-0.1.0.dist-info/RECORD +9 -0
- primecli-0.1.0.dist-info/WHEEL +5 -0
- primecli-0.1.0.dist-info/entry_points.txt +3 -0
- primecli-0.1.0.dist-info/licenses/LICENSE +21 -0
- primecli-0.1.0.dist-info/top_level.txt +1 -0
primecli/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()
|