jupiter-swap-python 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.
@@ -0,0 +1,19 @@
1
+ """
2
+ jupiter-swap-python — async Jupiter DEX aggregator client for Python.
3
+ """
4
+
5
+ from .client import JupiterClient
6
+ from .models import QuoteResponse, SwapResponse, TokenInfo, UltraOrder
7
+ from .tokens import TokenClient
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "JupiterClient",
13
+ "TokenClient",
14
+ "QuoteResponse",
15
+ "SwapResponse",
16
+ "UltraOrder",
17
+ "TokenInfo",
18
+ "__version__",
19
+ ]
jupiter_swap/client.py ADDED
@@ -0,0 +1,397 @@
1
+ """
2
+ Jupiter DEX aggregator client — V6 quote/swap and Ultra API.
3
+
4
+ Supports:
5
+ - Quote: get the best swap route and price
6
+ - Swap: build a transaction from a quote
7
+ - Ultra: combined quote + swap with MEV protection
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import time
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from .models import QuoteResponse, SwapResponse, UltraOrder
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Jupiter public API base URLs
24
+ _DEFAULT_V6_URL = "https://quote-api.jup.ag/v6"
25
+ _DEFAULT_ULTRA_URL = "https://api.jup.ag/ultra/v1"
26
+
27
+
28
+ class JupiterError(Exception):
29
+ """Raised when a Jupiter API call fails."""
30
+
31
+ def __init__(self, message: str, status_code: int = 0, body: str = "") -> None:
32
+ super().__init__(message)
33
+ self.status_code = status_code
34
+ self.body = body
35
+
36
+
37
+ class JupiterClient:
38
+ """
39
+ Async client for the Jupiter DEX aggregator.
40
+
41
+ Usage::
42
+
43
+ async with JupiterClient() as jup:
44
+ quote = await jup.get_quote("So11...", "EPjFW...", 1_000_000)
45
+ swap = await jup.get_swap_transaction(quote, "YourPublicKey...")
46
+
47
+ Handles 429 rate limiting with automatic retry and exponential backoff.
48
+ """
49
+
50
+ __slots__ = (
51
+ "_v6_url",
52
+ "_ultra_url",
53
+ "_api_key",
54
+ "_http",
55
+ "_max_retries",
56
+ "_last_request_time",
57
+ "_min_interval",
58
+ )
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ api_key: str = "",
64
+ v6_url: str = _DEFAULT_V6_URL,
65
+ ultra_url: str = _DEFAULT_ULTRA_URL,
66
+ max_retries: int = 3,
67
+ requests_per_second: float = 10.0,
68
+ ) -> None:
69
+ """
70
+ Initialise the Jupiter client.
71
+
72
+ Args:
73
+ api_key: Optional Jupiter API key for higher rate limits.
74
+ v6_url: Base URL for the V6 quote/swap API.
75
+ ultra_url: Base URL for the Ultra swap API.
76
+ max_retries: Max retry attempts on 429 responses.
77
+ requests_per_second: Simple rate limiter — minimum interval between requests.
78
+ """
79
+ self._v6_url = v6_url.rstrip("/")
80
+ self._ultra_url = ultra_url.rstrip("/")
81
+ self._api_key = api_key
82
+ self._http: httpx.AsyncClient | None = None
83
+ self._max_retries = max_retries
84
+ self._last_request_time: float = 0.0
85
+ self._min_interval: float = 1.0 / requests_per_second if requests_per_second > 0 else 0.0
86
+
87
+ # -- Lifecycle -----------------------------------------------------------
88
+
89
+ async def __aenter__(self) -> JupiterClient:
90
+ await self.connect()
91
+ return self
92
+
93
+ async def __aexit__(self, *_: Any) -> None:
94
+ await self.close()
95
+
96
+ async def connect(self) -> None:
97
+ """Open the HTTP connection pool."""
98
+ if self._http is None:
99
+ self._http = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
100
+
101
+ async def close(self) -> None:
102
+ """Close the HTTP connection pool."""
103
+ if self._http:
104
+ await self._http.aclose()
105
+ self._http = None
106
+
107
+ # -- V6 Quote/Swap -------------------------------------------------------
108
+
109
+ async def get_quote(
110
+ self,
111
+ input_mint: str,
112
+ output_mint: str,
113
+ amount: int,
114
+ slippage_bps: int = 50,
115
+ *,
116
+ swap_mode: str = "ExactIn",
117
+ restrict_intermediate_tokens: bool = True,
118
+ max_accounts: int = 64,
119
+ ) -> QuoteResponse:
120
+ """
121
+ Get the best swap route from Jupiter.
122
+
123
+ Args:
124
+ input_mint: Source token mint address.
125
+ output_mint: Destination token mint address.
126
+ amount: Amount in smallest unit (lamports for SOL).
127
+ slippage_bps: Maximum slippage in basis points (1 bps = 0.01%).
128
+ swap_mode: "ExactIn" or "ExactOut".
129
+ restrict_intermediate_tokens: Avoid illiquid intermediate hops.
130
+ max_accounts: Max accounts in the route (higher = more complex routes).
131
+
132
+ Returns:
133
+ QuoteResponse with route details and expected output amount.
134
+
135
+ Raises:
136
+ JupiterError: If the quote request fails.
137
+ """
138
+ params: dict[str, str | int] = {
139
+ "inputMint": input_mint,
140
+ "outputMint": output_mint,
141
+ "amount": str(amount),
142
+ "slippageBps": slippage_bps,
143
+ "swapMode": swap_mode,
144
+ }
145
+ if restrict_intermediate_tokens:
146
+ params["restrictIntermediateTokens"] = "true"
147
+ if max_accounts != 64:
148
+ params["maxAccounts"] = str(max_accounts)
149
+
150
+ data = await self._request("GET", f"{self._v6_url}/quote", params=params)
151
+
152
+ return QuoteResponse(
153
+ input_mint=data.get("inputMint", input_mint),
154
+ output_mint=data.get("outputMint", output_mint),
155
+ in_amount=data.get("inAmount", str(amount)),
156
+ out_amount=data.get("outAmount", "0"),
157
+ price_impact_pct=data.get("priceImpactPct", "0"),
158
+ route_plan=data.get("routePlan", []),
159
+ other_amount_threshold=data.get("otherAmountThreshold", "0"),
160
+ swap_mode=data.get("swapMode", swap_mode),
161
+ slippage_bps=data.get("slippageBps", slippage_bps),
162
+ raw=data,
163
+ )
164
+
165
+ async def get_swap_transaction(
166
+ self,
167
+ quote: QuoteResponse,
168
+ user_public_key: str,
169
+ *,
170
+ wrap_and_unwrap_sol: bool = True,
171
+ priority_fee_lamports: int = 0,
172
+ dynamic_compute_unit_limit: bool = True,
173
+ priority_level: str = "veryHigh",
174
+ ) -> SwapResponse:
175
+ """
176
+ Build a swap transaction from a quote.
177
+
178
+ Args:
179
+ quote: A QuoteResponse from get_quote().
180
+ user_public_key: The wallet public key that will sign the tx.
181
+ wrap_and_unwrap_sol: Auto wrap/unwrap SOL ↔ WSOL.
182
+ priority_fee_lamports: Max priority fee in lamports (0 = use default).
183
+ dynamic_compute_unit_limit: Auto-optimize compute unit limit.
184
+ priority_level: Priority level — "medium", "high", "veryHigh".
185
+
186
+ Returns:
187
+ SwapResponse with the base64-encoded transaction.
188
+
189
+ Raises:
190
+ JupiterError: If the swap request fails.
191
+ """
192
+ max_fee = priority_fee_lamports if priority_fee_lamports > 0 else 1_000_000
193
+
194
+ body: dict[str, Any] = {
195
+ "quoteResponse": quote.raw,
196
+ "userPublicKey": user_public_key,
197
+ "wrapAndUnwrapSol": wrap_and_unwrap_sol,
198
+ "dynamicComputeUnitLimit": dynamic_compute_unit_limit,
199
+ "dynamicSlippage": True,
200
+ "prioritizationFeeLamports": {
201
+ "priorityLevelWithMaxLamports": {
202
+ "maxLamports": max_fee,
203
+ "priorityLevel": priority_level,
204
+ },
205
+ },
206
+ }
207
+
208
+ data = await self._request("POST", f"{self._v6_url}/swap", json=body)
209
+
210
+ return SwapResponse(
211
+ swap_transaction=data.get("swapTransaction", ""),
212
+ last_valid_block_height=data.get("lastValidBlockHeight", 0),
213
+ priority_fee_lamports=data.get("prioritizationFeeLamports", 0),
214
+ raw=data,
215
+ )
216
+
217
+ # -- Ultra API -----------------------------------------------------------
218
+
219
+ async def ultra_order(
220
+ self,
221
+ input_mint: str,
222
+ output_mint: str,
223
+ amount: int,
224
+ taker: str,
225
+ slippage_bps: int = 50,
226
+ ) -> UltraOrder:
227
+ """
228
+ Request a swap via Jupiter Ultra API (combined quote + swap).
229
+
230
+ Ultra provides:
231
+ - Built-in MEV protection
232
+ - Automatic priority fee optimisation
233
+ - Better routing engine
234
+ - Gasless mode support
235
+
236
+ Args:
237
+ input_mint: Source token mint address.
238
+ output_mint: Destination token mint address.
239
+ amount: Amount in smallest unit.
240
+ taker: Wallet public key of the swap taker.
241
+ slippage_bps: Maximum slippage in basis points.
242
+
243
+ Returns:
244
+ UltraOrder with a transaction ready to sign and execute.
245
+
246
+ Raises:
247
+ JupiterError: If the order request fails.
248
+ """
249
+ body: dict[str, Any] = {
250
+ "inputMint": input_mint,
251
+ "outputMint": output_mint,
252
+ "amount": str(amount),
253
+ "taker": taker,
254
+ "slippageBps": slippage_bps,
255
+ }
256
+
257
+ data = await self._request("POST", f"{self._ultra_url}/order", json=body)
258
+
259
+ return UltraOrder(
260
+ request_id=data.get("requestId", ""),
261
+ input_mint=data.get("inputMint", input_mint),
262
+ output_mint=data.get("outputMint", output_mint),
263
+ in_amount=data.get("inAmount", str(amount)),
264
+ out_amount=data.get("outAmount", "0"),
265
+ swap_transaction=data.get("transaction", ""),
266
+ swap_type=data.get("type", "swap"),
267
+ priority_fee_lamports=data.get("prioritizationFeeLamports", 0),
268
+ dynamic_slippage_bps=data.get("dynamicSlippageReport", {}).get(
269
+ "slippageBps", slippage_bps,
270
+ ),
271
+ raw=data,
272
+ )
273
+
274
+ async def ultra_execute(
275
+ self,
276
+ signed_transaction: str,
277
+ request_id: str,
278
+ ) -> str:
279
+ """
280
+ Execute a signed Ultra swap order.
281
+
282
+ Submit the signed transaction back to Jupiter Ultra for
283
+ execution with MEV protection and confirmation tracking.
284
+
285
+ Args:
286
+ signed_transaction: Base64-encoded signed transaction.
287
+ request_id: The request_id from the UltraOrder.
288
+
289
+ Returns:
290
+ Transaction signature string.
291
+
292
+ Raises:
293
+ JupiterError: If execution fails.
294
+ """
295
+ body = {
296
+ "signedTransaction": signed_transaction,
297
+ "requestId": request_id,
298
+ }
299
+
300
+ data = await self._request("POST", f"{self._ultra_url}/execute", json=body)
301
+
302
+ status = data.get("status", "")
303
+ tx_sig = data.get("signature", "")
304
+
305
+ if status == "Failed":
306
+ raise JupiterError(
307
+ f"Ultra swap execution failed: {data.get('error', 'unknown')}",
308
+ )
309
+
310
+ return tx_sig
311
+
312
+ # -- Internal ------------------------------------------------------------
313
+
314
+ def _auth_headers(self) -> dict[str, str]:
315
+ if self._api_key:
316
+ return {"x-api-key": self._api_key}
317
+ return {}
318
+
319
+ async def _rate_limit(self) -> None:
320
+ """Simple rate limiter — ensure minimum interval between requests."""
321
+ if self._min_interval <= 0:
322
+ return
323
+ now = time.monotonic()
324
+ elapsed = now - self._last_request_time
325
+ if elapsed < self._min_interval:
326
+ await asyncio.sleep(self._min_interval - elapsed)
327
+ self._last_request_time = time.monotonic()
328
+
329
+ async def _request(
330
+ self,
331
+ method: str,
332
+ url: str,
333
+ *,
334
+ params: dict | None = None,
335
+ json: dict | None = None,
336
+ ) -> dict:
337
+ """
338
+ Make an HTTP request with rate limiting and 429 retry.
339
+
340
+ Returns parsed JSON dict on success.
341
+ Raises JupiterError on failure.
342
+ """
343
+ if self._http is None:
344
+ raise JupiterError("Client not connected — call connect() or use async with")
345
+
346
+ await self._rate_limit()
347
+
348
+ last_status = 0
349
+ last_body = ""
350
+
351
+ for attempt in range(self._max_retries + 1):
352
+ try:
353
+ if method == "GET":
354
+ resp = await self._http.get(url, params=params, headers=self._auth_headers())
355
+ else:
356
+ resp = await self._http.post(url, json=json, headers=self._auth_headers())
357
+
358
+ last_status = resp.status_code
359
+ last_body = resp.text[:500] if resp.status_code != 200 else ""
360
+
361
+ if resp.status_code == 200:
362
+ return resp.json()
363
+
364
+ if resp.status_code == 429:
365
+ retry_after = self._parse_retry_after(resp)
366
+ wait = retry_after if retry_after else 0.5 * (attempt + 1)
367
+ logger.warning(
368
+ "Jupiter 429 rate limited (attempt %d/%d), waiting %.1fs",
369
+ attempt + 1, self._max_retries + 1, wait,
370
+ )
371
+ await asyncio.sleep(wait)
372
+ continue
373
+
374
+ # Non-retryable error
375
+ break
376
+
377
+ except httpx.TimeoutException as exc:
378
+ if attempt < self._max_retries:
379
+ await asyncio.sleep(0.5 * (attempt + 1))
380
+ continue
381
+ raise JupiterError(f"Request timed out: {exc}") from exc
382
+
383
+ raise JupiterError(
384
+ f"Jupiter API error (HTTP {last_status})",
385
+ status_code=last_status,
386
+ body=last_body,
387
+ )
388
+
389
+ @staticmethod
390
+ def _parse_retry_after(resp: httpx.Response) -> float | None:
391
+ header = resp.headers.get("Retry-After")
392
+ if not header:
393
+ return None
394
+ try:
395
+ return max(0.0, float(header))
396
+ except (TypeError, ValueError):
397
+ return None
jupiter_swap/models.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ Data models for Jupiter API responses.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class QuoteResponse:
12
+ """Parsed quote from Jupiter V6 API."""
13
+
14
+ input_mint: str
15
+ output_mint: str
16
+ in_amount: str
17
+ out_amount: str
18
+ price_impact_pct: str = "0"
19
+ route_plan: list[dict] = field(default_factory=list)
20
+ other_amount_threshold: str = "0"
21
+ swap_mode: str = "ExactIn"
22
+ slippage_bps: int = 50
23
+ raw: dict = field(default_factory=dict, repr=False)
24
+
25
+ @property
26
+ def in_amount_float(self) -> float:
27
+ """Input amount as float (divide by decimals yourself)."""
28
+ try:
29
+ return float(self.in_amount)
30
+ except (TypeError, ValueError):
31
+ return 0.0
32
+
33
+ @property
34
+ def out_amount_float(self) -> float:
35
+ """Output amount as float (divide by decimals yourself)."""
36
+ try:
37
+ return float(self.out_amount)
38
+ except (TypeError, ValueError):
39
+ return 0.0
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class SwapResponse:
44
+ """Parsed swap transaction from Jupiter V6 API."""
45
+
46
+ swap_transaction: str
47
+ last_valid_block_height: int = 0
48
+ priority_fee_lamports: int = 0
49
+ raw: dict = field(default_factory=dict, repr=False)
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class UltraOrder:
54
+ """Response from Jupiter Ultra API — combined quote + swap in one call."""
55
+
56
+ request_id: str
57
+ input_mint: str
58
+ output_mint: str
59
+ in_amount: str
60
+ out_amount: str
61
+ swap_transaction: str
62
+ swap_type: str = "swap"
63
+ priority_fee_lamports: int = 0
64
+ dynamic_slippage_bps: int = 0
65
+ raw: dict = field(default_factory=dict, repr=False)
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class TokenInfo:
70
+ """Token metadata from Jupiter Token API."""
71
+
72
+ address: str
73
+ name: str = ""
74
+ symbol: str = ""
75
+ decimals: int = 0
76
+ logo_uri: str = ""
77
+ tags: list[str] = field(default_factory=list)
78
+ daily_volume: float | None = None
79
+ freeze_authority: str | None = None
80
+ mint_authority: str | None = None
81
+ is_verified: bool = False
82
+ is_banned: bool = False
jupiter_swap/py.typed ADDED
File without changes
jupiter_swap/tokens.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ Jupiter Token API client — token verification, metadata, and banned detection.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import time
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .models import TokenInfo
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _DEFAULT_TOKEN_URL = "https://tokens.jup.ag"
18
+
19
+ # Cache TTLs
20
+ _TOKEN_INFO_TTL: float = 300.0
21
+ _BANNED_LIST_TTL: float = 600.0
22
+
23
+
24
+ class TokenClient:
25
+ """
26
+ Async client for the Jupiter Token API.
27
+
28
+ Provides token metadata, verification status, and banned token detection.
29
+
30
+ Usage::
31
+
32
+ async with TokenClient() as tokens:
33
+ info = await tokens.get_token_info("EPjFWdd5...")
34
+ print(info.symbol, info.is_verified)
35
+
36
+ is_scam = await tokens.is_banned("ScamMint...")
37
+ """
38
+
39
+ __slots__ = (
40
+ "_base_url",
41
+ "_api_key",
42
+ "_http",
43
+ "_banned_mints",
44
+ "_banned_loaded",
45
+ "_cache",
46
+ "_cache_ttl",
47
+ )
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ base_url: str = _DEFAULT_TOKEN_URL,
53
+ api_key: str = "",
54
+ cache_ttl: float = _TOKEN_INFO_TTL,
55
+ ) -> None:
56
+ self._base_url = base_url.rstrip("/")
57
+ self._api_key = api_key
58
+ self._http: httpx.AsyncClient | None = None
59
+ self._banned_mints: set[str] = set()
60
+ self._banned_loaded: bool = False
61
+ self._cache: dict[str, tuple[float, Any]] = {}
62
+ self._cache_ttl = cache_ttl
63
+
64
+ # -- Lifecycle -----------------------------------------------------------
65
+
66
+ async def __aenter__(self) -> TokenClient:
67
+ await self.connect()
68
+ return self
69
+
70
+ async def __aexit__(self, *_: Any) -> None:
71
+ await self.close()
72
+
73
+ async def connect(self) -> None:
74
+ """Open HTTP client and preload the banned token list."""
75
+ headers: dict[str, str] = {}
76
+ if self._api_key:
77
+ headers["x-api-key"] = self._api_key
78
+ self._http = httpx.AsyncClient(
79
+ timeout=httpx.Timeout(15.0),
80
+ headers=headers,
81
+ )
82
+ await self._refresh_banned_list()
83
+
84
+ async def close(self) -> None:
85
+ """Close the HTTP client."""
86
+ if self._http:
87
+ await self._http.aclose()
88
+ self._http = None
89
+
90
+ # -- Public API ----------------------------------------------------------
91
+
92
+ async def get_token_info(self, mint: str) -> TokenInfo:
93
+ """
94
+ Get token metadata and verification status.
95
+
96
+ Args:
97
+ mint: Token mint address.
98
+
99
+ Returns:
100
+ TokenInfo with name, symbol, decimals, verification flags.
101
+
102
+ Raises:
103
+ httpx.HTTPError: On network failure.
104
+ """
105
+ cached = self._get_cached(f"token:{mint}")
106
+ if cached is not None:
107
+ return cached
108
+
109
+ if self._http is None:
110
+ raise RuntimeError("TokenClient not connected — call connect() first")
111
+
112
+ resp = await self._http.get(f"{self._base_url}/tokens/v1/{mint}")
113
+
114
+ if resp.status_code == 404:
115
+ token = TokenInfo(
116
+ address=mint,
117
+ is_verified=False,
118
+ is_banned=mint in self._banned_mints,
119
+ )
120
+ self._set_cached(f"token:{mint}", token)
121
+ return token
122
+
123
+ resp.raise_for_status()
124
+
125
+ data = resp.json()
126
+ tags = data.get("tags", [])
127
+ token = TokenInfo(
128
+ address=data.get("address", mint),
129
+ name=data.get("name", ""),
130
+ symbol=data.get("symbol", ""),
131
+ decimals=data.get("decimals", 0),
132
+ logo_uri=data.get("logoURI", ""),
133
+ tags=tags,
134
+ daily_volume=data.get("daily_volume"),
135
+ freeze_authority=data.get("freeze_authority"),
136
+ mint_authority=data.get("mint_authority"),
137
+ is_verified="verified" in tags or "strict" in tags or "community" in tags,
138
+ is_banned=mint in self._banned_mints,
139
+ )
140
+ self._set_cached(f"token:{mint}", token)
141
+ return token
142
+
143
+ async def is_banned(self, mint: str) -> bool:
144
+ """Check if a token is on Jupiter's banned list (known scams)."""
145
+ if mint in self._banned_mints:
146
+ return True
147
+ if not self._banned_loaded:
148
+ await self._refresh_banned_list()
149
+ return mint in self._banned_mints
150
+
151
+ async def is_verified(self, mint: str) -> bool:
152
+ """Check if a token is on the verified list."""
153
+ info = await self.get_token_info(mint)
154
+ return info.is_verified
155
+
156
+ async def get_strict_list(self) -> list[TokenInfo]:
157
+ """
158
+ Get the strictly verified token list (vetted tokens only).
159
+
160
+ Returns:
161
+ List of TokenInfo for all strictly verified tokens.
162
+ """
163
+ cached = self._get_cached("strict_list")
164
+ if cached is not None:
165
+ return cached
166
+
167
+ if self._http is None:
168
+ raise RuntimeError("TokenClient not connected — call connect() first")
169
+
170
+ resp = await self._http.get(f"{self._base_url}/tokens/v1/strict")
171
+ resp.raise_for_status()
172
+
173
+ tokens = []
174
+ for item in resp.json():
175
+ if isinstance(item, dict):
176
+ tags = item.get("tags", [])
177
+ tokens.append(TokenInfo(
178
+ address=item.get("address", ""),
179
+ name=item.get("name", ""),
180
+ symbol=item.get("symbol", ""),
181
+ decimals=item.get("decimals", 0),
182
+ logo_uri=item.get("logoURI", ""),
183
+ tags=tags,
184
+ daily_volume=item.get("daily_volume"),
185
+ freeze_authority=item.get("freeze_authority"),
186
+ mint_authority=item.get("mint_authority"),
187
+ is_verified=True,
188
+ is_banned=False,
189
+ ))
190
+
191
+ self._set_cached("strict_list", tokens, ttl=_BANNED_LIST_TTL)
192
+ return tokens
193
+
194
+ async def refresh_banned_list(self) -> int:
195
+ """
196
+ Manually refresh the banned token list.
197
+
198
+ Returns:
199
+ Number of banned tokens loaded.
200
+ """
201
+ await self._refresh_banned_list()
202
+ return len(self._banned_mints)
203
+
204
+ # -- Internal helpers ----------------------------------------------------
205
+
206
+ async def _refresh_banned_list(self) -> None:
207
+ if self._http is None:
208
+ return
209
+ try:
210
+ resp = await self._http.get(f"{self._base_url}/tokens/v1/banned")
211
+ if resp.status_code == 200:
212
+ data = resp.json()
213
+ if isinstance(data, list):
214
+ self._banned_mints = {
215
+ item.get("address", item) if isinstance(item, dict) else str(item)
216
+ for item in data
217
+ }
218
+ self._banned_loaded = True
219
+ logger.debug("Loaded %d banned tokens", len(self._banned_mints))
220
+ except Exception as exc:
221
+ logger.warning("Failed to load banned token list: %s", exc)
222
+
223
+ def _get_cached(self, key: str) -> Any | None:
224
+ if key in self._cache:
225
+ expires, value = self._cache[key]
226
+ if time.monotonic() < expires:
227
+ return value
228
+ del self._cache[key]
229
+ return None
230
+
231
+ def _set_cached(self, key: str, value: Any, ttl: float | None = None) -> None:
232
+ self._cache[key] = (time.monotonic() + (ttl or self._cache_ttl), value)
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: jupiter-swap-python
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the Jupiter DEX aggregator on Solana — V6 quote/swap and Ultra API.
5
+ Project-URL: Homepage, https://github.com/JinUltimate1995/jupiter-swap-python
6
+ Project-URL: Repository, https://github.com/JinUltimate1995/jupiter-swap-python
7
+ Project-URL: Issues, https://github.com/JinUltimate1995/jupiter-swap-python/issues
8
+ Project-URL: Changelog, https://github.com/JinUltimate1995/jupiter-swap-python/blob/main/CHANGELOG.md
9
+ Author: JinUltimate1995
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: aggregator,async,crypto,defi,dex,jupiter,python,solana,swap,trading
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: httpx>=0.27
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # jupiter-swap-python
34
+
35
+ **The missing Jupiter swap client for Python. Async. Typed. Production-tested.**
36
+
37
+ [![PyPI version](https://img.shields.io/pypi/v/jupiter-swap-python?color=blue)](https://pypi.org/project/jupiter-swap-python/)
38
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
40
+ [![CI](https://github.com/JinUltimate1995/jupiter-swap-python/actions/workflows/ci.yml/badge.svg)](https://github.com/JinUltimate1995/jupiter-swap-python/actions)
41
+
42
+ Jupiter's own Python SDK was abandoned (returns 404). This library fills the gap — a clean, typed, async client for [Jupiter](https://jup.ag), the #1 DEX aggregator on Solana.
43
+
44
+ ---
45
+
46
+ ## ⚡ Quickstart
47
+
48
+ ```bash
49
+ pip install jupiter-swap-python
50
+ ```
51
+
52
+ ### Get a quote
53
+
54
+ ```python
55
+ import asyncio
56
+ from jupiter_swap import JupiterClient
57
+
58
+ SOL = "So11111111111111111111111111111111111111112"
59
+ USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
60
+
61
+ async def main():
62
+ async with JupiterClient() as jup:
63
+ quote = await jup.get_quote(SOL, USDC, 1_000_000_000) # 1 SOL
64
+ print(f"1 SOL = {int(quote.out_amount) / 1e6:.2f} USDC")
65
+ print(f"Price impact: {quote.price_impact_pct}%")
66
+
67
+ asyncio.run(main())
68
+ ```
69
+
70
+ ### Swap tokens
71
+
72
+ ```python
73
+ async with JupiterClient(api_key="your-key") as jup:
74
+ # Step 1: Get quote
75
+ quote = await jup.get_quote(SOL, USDC, 1_000_000_000)
76
+
77
+ # Step 2: Build swap transaction
78
+ swap = await jup.get_swap_transaction(
79
+ quote,
80
+ user_public_key="YourWalletPublicKey...",
81
+ priority_fee_lamports=500_000, # 0.0005 SOL
82
+ )
83
+
84
+ # Step 3: Sign and send swap.swap_transaction with your wallet
85
+ print(f"Transaction ready (block height: {swap.last_valid_block_height})")
86
+ ```
87
+
88
+ ### Ultra API (recommended for production)
89
+
90
+ ```python
91
+ async with JupiterClient(api_key="your-key") as jup:
92
+ # Combined quote + swap in one call
93
+ order = await jup.ultra_order(
94
+ input_mint=SOL,
95
+ output_mint=USDC,
96
+ amount=1_000_000_000,
97
+ taker="YourWalletPublicKey...",
98
+ )
99
+
100
+ print(f"Swap type: {order.swap_type}")
101
+ print(f"Out amount: {order.out_amount}")
102
+
103
+ # Sign order.swap_transaction with your wallet, then:
104
+ tx_sig = await jup.ultra_execute(signed_transaction, order.request_id)
105
+ print(f"Executed: {tx_sig}")
106
+ ```
107
+
108
+ ### Token verification
109
+
110
+ ```python
111
+ from jupiter_swap import TokenClient
112
+
113
+ async with TokenClient() as tokens:
114
+ info = await tokens.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
115
+ print(f"{info.symbol} — verified: {info.is_verified}, banned: {info.is_banned}")
116
+
117
+ # Check if a token is a known scam
118
+ if await tokens.is_banned("SomeScamMint..."):
119
+ print("🚫 Token is banned!")
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 🔧 API Reference
125
+
126
+ ### `JupiterClient`
127
+
128
+ | Method | Description |
129
+ |--------|-------------|
130
+ | `get_quote()` | Get the best swap route and price |
131
+ | `get_swap_transaction()` | Build a signable transaction from a quote |
132
+ | `ultra_order()` | Combined quote + swap via Ultra API (MEV protection) |
133
+ | `ultra_execute()` | Execute a signed Ultra swap order |
134
+
135
+ ### `TokenClient`
136
+
137
+ | Method | Description |
138
+ |--------|-------------|
139
+ | `get_token_info()` | Get token metadata and verification status |
140
+ | `is_banned()` | Check if a token is on the banned list |
141
+ | `is_verified()` | Check if a token is verified |
142
+ | `get_strict_list()` | Get all strictly verified tokens |
143
+ | `refresh_banned_list()` | Manually refresh the banned list |
144
+
145
+ ### Models
146
+
147
+ - `QuoteResponse` — Route details, expected output, price impact
148
+ - `SwapResponse` — Base64 transaction ready to sign
149
+ - `UltraOrder` — Combined quote + transaction from Ultra API
150
+ - `TokenInfo` — Name, symbol, decimals, verification flags
151
+
152
+ ---
153
+
154
+ ## 🛡️ Why this library?
155
+
156
+ | Problem | Solution |
157
+ |---------|----------|
158
+ | Jupiter's Python SDK is deleted | This exists |
159
+ | Raw `httpx.post()` calls everywhere | Typed client with proper models |
160
+ | No 429 handling | Built-in retry with exponential backoff |
161
+ | Token safety is an afterthought | `TokenClient` with banned/verified checks |
162
+ | Sync-only clients | Fully async with `httpx` |
163
+
164
+ ---
165
+
166
+ ## 🔑 API Key
167
+
168
+ A Jupiter API key is optional but recommended for production use (higher rate limits).
169
+
170
+ Get one at [station.jup.ag](https://station.jup.ag/).
171
+
172
+ ```python
173
+ client = JupiterClient(api_key="your-api-key")
174
+ ```
175
+
176
+ ---
177
+
178
+ ## 📦 Also by JinUltimate1995
179
+
180
+ - [**solana-rpc-resilient**](https://github.com/JinUltimate1995/solana-rpc-resilient) — Solana RPC client with automatic failover, rate limiting, and circuit breaker
181
+ - [**dexscreener-python**](https://github.com/JinUltimate1995/dexscreener-python) — Async DexScreener API client for token/pair data across 80+ chains
182
+
183
+ ---
184
+
185
+ ## License
186
+
187
+ MIT
@@ -0,0 +1,9 @@
1
+ jupiter_swap/__init__.py,sha256=0QHLBTFYjdNF3w9mv8uXvKVtHoCZLx7uY7UHlZ5HZ00,391
2
+ jupiter_swap/client.py,sha256=bbpxnxdAAcABWeZ7amdNjbrXO-kwytGKM_d6eI9TrYo,13062
3
+ jupiter_swap/models.py,sha256=PXlpq_1_f_YhYxq3MyvccNK-sjevrIpnnR4t7IHSTJo,2095
4
+ jupiter_swap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ jupiter_swap/tokens.py,sha256=RFgZExVcBtYWrqDhcKmwiKKllw9Xpejm2SAdfiG2hEs,7387
6
+ jupiter_swap_python-0.1.0.dist-info/METADATA,sha256=okCLod9i4wvpGL5-psxWMoYpfhN9oNzM-JPZYMkp5WM,6271
7
+ jupiter_swap_python-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ jupiter_swap_python-0.1.0.dist-info/licenses/LICENSE,sha256=D7y6S1R0LcQMuzvEM3af4NM2zASwS0H82DKH-3g_FDE,1072
9
+ jupiter_swap_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 JinUltimate1995
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.