defuse-py 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.
defuse/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ from .client import IntentsClient
2
+ from .enums import DepositMode, DepositType, RecipientType, RefundType, SwapStatus, SwapType
3
+ from .exceptions import IntentsAPIError, IntentsAuthError, IntentsError
4
+ from .models import (
5
+ AppFee,
6
+ AnyInputWithdrawalsResponse,
7
+ ExecutionStatusResponse,
8
+ Quote,
9
+ QuoteRequest,
10
+ QuoteResponse,
11
+ SubmitDepositRequest,
12
+ SubmitDepositResponse,
13
+ TokenResponse,
14
+ )
15
+ from .utils import find_token, from_atomic, to_atomic, token_from_atomic, token_to_atomic, pct_to_bps, bps_to_pct
16
+
17
+ __all__ = [
18
+ "IntentsClient",
19
+ "DepositMode",
20
+ "DepositType",
21
+ "RecipientType",
22
+ "RefundType",
23
+ "SwapStatus",
24
+ "SwapType",
25
+ "IntentsAPIError",
26
+ "IntentsAuthError",
27
+ "IntentsError",
28
+ "AppFee",
29
+ "AnyInputWithdrawalsResponse",
30
+ "ExecutionStatusResponse",
31
+ "Quote",
32
+ "QuoteRequest",
33
+ "QuoteResponse",
34
+ "SubmitDepositRequest",
35
+ "SubmitDepositResponse",
36
+ "TokenResponse",
37
+ "find_token",
38
+ "from_atomic",
39
+ "to_atomic",
40
+ "token_from_atomic",
41
+ "token_to_atomic",
42
+ "pct_to_bps",
43
+ "bps_to_pct",
44
+ ]
defuse/client.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ from types import TracebackType
4
+ from typing import Literal
5
+
6
+ import httpx
7
+
8
+ from .exceptions import IntentsAPIError, IntentsAuthError
9
+ from .models import (
10
+ AnyInputWithdrawalsResponse,
11
+ ExecutionStatusResponse,
12
+ QuoteRequest,
13
+ QuoteResponse,
14
+ SubmitDepositRequest,
15
+ SubmitDepositResponse,
16
+ TokenResponse,
17
+ )
18
+
19
+ _BASE_URL = "https://1click.chaindefuser.com"
20
+
21
+
22
+ class IntentsClient:
23
+ """Async client for the NEAR Intents 1Click Swap API.
24
+
25
+ Can be used as an async context manager or standalone. When used standalone,
26
+ call :meth:`aclose` when finished.
27
+
28
+ Args:
29
+ jwt_token: Optional Bearer JWT token for authenticated endpoints.
30
+ base_url: Override the default API base URL.
31
+ timeout: Request timeout in seconds (default: 30).
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ jwt_token: str | None = None,
37
+ base_url: str = _BASE_URL,
38
+ timeout: float = 30.0,
39
+ ) -> None:
40
+ headers: dict[str, str] = {"Accept": "application/json", "Content-Type": "application/json"}
41
+ if jwt_token:
42
+ headers["Authorization"] = f"Bearer {jwt_token}"
43
+
44
+ self._http = httpx.AsyncClient(
45
+ base_url=base_url,
46
+ headers=headers,
47
+ timeout=timeout,
48
+ )
49
+
50
+ async def __aenter__(self) -> IntentsClient:
51
+ return self
52
+
53
+ async def __aexit__(
54
+ self,
55
+ exc_type: type[BaseException] | None,
56
+ exc_val: BaseException | None,
57
+ exc_tb: TracebackType | None,
58
+ ) -> None:
59
+ await self.aclose()
60
+
61
+ async def aclose(self) -> None:
62
+ await self._http.aclose()
63
+
64
+ async def _raise_for_status(self, response: httpx.Response) -> None:
65
+ if response.status_code == 401:
66
+ raise IntentsAuthError(401, "Unauthorized — JWT token is missing or invalid")
67
+ if response.is_error:
68
+ try:
69
+ message = self._parse_json(response).get("message", response.text)
70
+ except Exception:
71
+ message = response.text
72
+ raise IntentsAPIError(response.status_code, message)
73
+
74
+ def _parse_json(self, response: httpx.Response) -> object:
75
+ if not response.content:
76
+ raise IntentsAPIError(response.status_code, "Empty response body")
77
+ return response.json()
78
+
79
+ async def get_tokens(self) -> list[TokenResponse]:
80
+ """Retrieve all tokens supported by the 1Click API.
81
+
82
+ Returns:
83
+ List of :class:`~intents.models.TokenResponse` objects.
84
+ """
85
+ response = await self._http.get("/v0/tokens")
86
+ await self._raise_for_status(response)
87
+ return [TokenResponse.model_validate(item) for item in self._parse_json(response)] # type: ignore[union-attr]
88
+
89
+ async def get_quote(self, request: QuoteRequest) -> QuoteResponse:
90
+ """Request a swap quote.
91
+
92
+ Pass ``dry=True`` on the request to simulate the quote without
93
+ generating a deposit address.
94
+
95
+ Args:
96
+ request: Swap parameters as a :class:`~intents.models.QuoteRequest`.
97
+
98
+ Returns:
99
+ :class:`~intents.models.QuoteResponse` with the deposit address
100
+ and expected output amounts.
101
+
102
+ Raises:
103
+ IntentsAPIError: On HTTP 400 (bad request).
104
+ IntentsAuthError: On HTTP 401 (invalid JWT).
105
+ """
106
+ response = await self._http.post(
107
+ "/v0/quote",
108
+ json=request.to_api_dict(),
109
+
110
+ )
111
+ await self._raise_for_status(response)
112
+ return QuoteResponse.model_validate(self._parse_json(response))
113
+
114
+ async def get_status(
115
+ self,
116
+ deposit_address: str,
117
+ deposit_memo: str | None = None,
118
+ ) -> ExecutionStatusResponse:
119
+ """Check the execution status of a swap.
120
+
121
+ Args:
122
+ deposit_address: The deposit address returned by :meth:`get_quote`.
123
+ deposit_memo: Required for chains that use memo-based deposits.
124
+
125
+ Returns:
126
+ :class:`~intents.models.ExecutionStatusResponse`.
127
+
128
+ Raises:
129
+ IntentsAPIError: On HTTP 404 (deposit address not found).
130
+ IntentsAuthError: On HTTP 401 (invalid JWT).
131
+ """
132
+ params: dict[str, str] = {"depositAddress": deposit_address}
133
+ if deposit_memo is not None:
134
+ params["depositMemo"] = deposit_memo
135
+
136
+ response = await self._http.get(
137
+ "/v0/status",
138
+ params=params,
139
+
140
+ )
141
+ await self._raise_for_status(response)
142
+ return ExecutionStatusResponse.model_validate(self._parse_json(response))
143
+
144
+ async def get_any_input_withdrawals(
145
+ self,
146
+ deposit_address: str,
147
+ deposit_memo: str | None = None,
148
+ timestamp_from: str | None = None,
149
+ page: int | None = None,
150
+ limit: int | None = None,
151
+ sort_order: Literal["asc", "desc"] | None = None,
152
+ ) -> AnyInputWithdrawalsResponse:
153
+ """Retrieve withdrawals for an ``ANY_INPUT`` quote.
154
+
155
+ Args:
156
+ deposit_address: The deposit address for the quote.
157
+ deposit_memo: Memo used with the deposit, if applicable.
158
+ timestamp_from: ISO timestamp to filter withdrawals from.
159
+ page: Page number for pagination (default: 1).
160
+ limit: Withdrawals per page (max 50, default: 50).
161
+ sort_order: ``"asc"`` or ``"desc"``.
162
+
163
+ Returns:
164
+ :class:`~intents.models.AnyInputWithdrawalsResponse`.
165
+ """
166
+ params: dict[str, str | int] = {"depositAddress": deposit_address}
167
+ if deposit_memo is not None:
168
+ params["depositMemo"] = deposit_memo
169
+ if timestamp_from is not None:
170
+ params["timestampFrom"] = timestamp_from
171
+ if page is not None:
172
+ params["page"] = page
173
+ if limit is not None:
174
+ params["limit"] = limit
175
+ if sort_order is not None:
176
+ params["sortOrder"] = sort_order
177
+
178
+ response = await self._http.get(
179
+ "/v0/any-input/withdrawals",
180
+ params=params,
181
+
182
+ )
183
+ await self._raise_for_status(response)
184
+ return AnyInputWithdrawalsResponse.model_validate(self._parse_json(response))
185
+
186
+ async def submit_deposit(self, request: SubmitDepositRequest) -> SubmitDepositResponse:
187
+ """Notify the 1Click service that a deposit transaction has been sent.
188
+
189
+ This is optional but can speed up swap processing by allowing the
190
+ service to preemptively verify the deposit.
191
+
192
+ Args:
193
+ request: Deposit details as a :class:`~intents.models.SubmitDepositRequest`.
194
+
195
+ Returns:
196
+ :class:`~intents.models.SubmitDepositResponse`.
197
+
198
+ Raises:
199
+ IntentsAPIError: On HTTP 400 (bad request).
200
+ IntentsAuthError: On HTTP 401 (invalid JWT).
201
+ """
202
+ response = await self._http.post(
203
+ "/v0/deposit/submit",
204
+ json=request.to_api_dict(),
205
+
206
+ )
207
+ await self._raise_for_status(response)
208
+ return SubmitDepositResponse.model_validate(self._parse_json(response))
defuse/enums.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class SwapType(str, Enum):
7
+ EXACT_INPUT = "EXACT_INPUT"
8
+ EXACT_OUTPUT = "EXACT_OUTPUT"
9
+ FLEX_INPUT = "FLEX_INPUT"
10
+ ANY_INPUT = "ANY_INPUT"
11
+
12
+
13
+ class DepositType(str, Enum):
14
+ ORIGIN_CHAIN = "ORIGIN_CHAIN"
15
+ INTENTS = "INTENTS"
16
+
17
+
18
+ class RecipientType(str, Enum):
19
+ DESTINATION_CHAIN = "DESTINATION_CHAIN"
20
+ INTENTS = "INTENTS"
21
+
22
+
23
+ class DepositMode(str, Enum):
24
+ SIMPLE = "SIMPLE"
25
+ MEMO = "MEMO"
26
+
27
+
28
+ class RefundType(str, Enum):
29
+ ORIGIN_CHAIN = "ORIGIN_CHAIN"
30
+ INTENTS = "INTENTS"
31
+
32
+
33
+ class SwapStatus(str, Enum):
34
+ KNOWN_DEPOSIT_TX = "KNOWN_DEPOSIT_TX"
35
+ PENDING_DEPOSIT = "PENDING_DEPOSIT"
36
+ INCOMPLETE_DEPOSIT = "INCOMPLETE_DEPOSIT"
37
+ PROCESSING = "PROCESSING"
38
+ SUCCESS = "SUCCESS"
39
+ REFUNDED = "REFUNDED"
40
+ FAILED = "FAILED"
defuse/exceptions.py ADDED
@@ -0,0 +1,15 @@
1
+ class IntentsError(Exception):
2
+ """Base exception for all Intents API errors."""
3
+
4
+
5
+ class IntentsAPIError(IntentsError):
6
+ """Raised when the API returns an error response."""
7
+
8
+ def __init__(self, status_code: int, message: str) -> None:
9
+ self.status_code = status_code
10
+ self.message = message
11
+ super().__init__(f"HTTP {status_code}: {message}")
12
+
13
+
14
+ class IntentsAuthError(IntentsAPIError):
15
+ """Raised when the API returns a 401 Unauthorized response."""
defuse/models.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+ from pydantic.alias_generators import to_camel
7
+
8
+ from .enums import DepositMode, DepositType, RecipientType, RefundType, SwapStatus, SwapType
9
+
10
+ _camel = ConfigDict(alias_generator=to_camel, populate_by_name=True)
11
+
12
+
13
+ class TokenResponse(BaseModel):
14
+ model_config = _camel
15
+
16
+ asset_id: str
17
+ decimals: int
18
+ blockchain: str
19
+ symbol: str
20
+ price: float
21
+ price_updated_at: datetime
22
+ contract_address: str | None = None
23
+
24
+
25
+ class AppFee(BaseModel):
26
+ model_config = _camel
27
+
28
+ recipient: str
29
+ fee: int = Field(ge=0, description="Fee in basis points")
30
+
31
+
32
+ class QuoteRequest(BaseModel):
33
+ model_config = _camel
34
+
35
+ dry: bool
36
+ swap_type: SwapType | str
37
+ slippage_tolerance: int = Field(description="Slippage in basis points")
38
+ origin_asset: str
39
+ deposit_type: DepositType | str
40
+ destination_asset: str
41
+ amount: str
42
+ refund_to: str
43
+ refund_type: RefundType | str
44
+ recipient: str
45
+ recipient_type: RecipientType | str
46
+ deadline: datetime
47
+ deposit_mode: DepositMode | str = DepositMode.SIMPLE
48
+ connected_wallets: list[str] | None = None
49
+ session_id: str | None = None
50
+ virtual_chain_recipient: str | None = None
51
+ virtual_chain_refund_recipient: str | None = None
52
+ custom_recipient_msg: str | None = None
53
+ referral: str | None = None
54
+ quote_waiting_time_ms: int = 3000
55
+ app_fees: list[AppFee] | None = None
56
+
57
+ def to_api_dict(self) -> dict:
58
+ return self.model_dump(by_alias=True, exclude_none=True, mode="json")
59
+
60
+
61
+ class Quote(BaseModel):
62
+ model_config = _camel
63
+
64
+ amount_in: str
65
+ amount_in_formatted: str
66
+ amount_in_usd: str
67
+ min_amount_in: str
68
+ max_amount_in: str | None = None
69
+ amount_out: str
70
+ amount_out_formatted: str
71
+ amount_out_usd: str
72
+ min_amount_out: str
73
+ time_estimate: int = Field(description="Estimated swap time in seconds")
74
+ deposit_address: str | None = None
75
+ deposit_memo: str | None = None
76
+ deadline: datetime | None = None
77
+ time_when_inactive: datetime | None = None
78
+ virtual_chain_recipient: str | None = None
79
+ virtual_chain_refund_recipient: str | None = None
80
+ custom_recipient_msg: str | None = None
81
+ refund_fee: str | None = None
82
+
83
+
84
+ class QuoteResponse(BaseModel):
85
+ model_config = _camel
86
+
87
+ correlation_id: str | None = None
88
+ timestamp: datetime
89
+ signature: str
90
+ quote_request: QuoteRequest
91
+ quote: Quote
92
+
93
+
94
+ class TransactionDetails(BaseModel):
95
+ model_config = _camel
96
+
97
+ hash: str
98
+ explorer_url: str
99
+
100
+
101
+ class SwapDetails(BaseModel):
102
+ model_config = _camel
103
+
104
+ intent_hashes: list[str]
105
+ near_tx_hashes: list[str]
106
+ origin_chain_tx_hashes: list[TransactionDetails]
107
+ destination_chain_tx_hashes: list[TransactionDetails]
108
+ amount_in: str | None = None
109
+ amount_in_formatted: str | None = None
110
+ amount_in_usd: str | None = None
111
+ amount_out: str | None = None
112
+ amount_out_formatted: str | None = None
113
+ amount_out_usd: str | None = None
114
+ slippage: int | None = None
115
+ refunded_amount: str | None = None
116
+ refunded_amount_formatted: str | None = None
117
+ refunded_amount_usd: str | None = None
118
+ refund_reason: str | None = None
119
+ deposited_amount: str | None = None
120
+ deposited_amount_formatted: str | None = None
121
+ deposited_amount_usd: str | None = None
122
+ referral: str | None = None
123
+
124
+
125
+ class ExecutionStatusResponse(BaseModel):
126
+ model_config = _camel
127
+
128
+ correlation_id: str
129
+ quote_response: QuoteResponse
130
+ status: SwapStatus
131
+ updated_at: datetime
132
+ swap_details: SwapDetails
133
+
134
+
135
+ class AnyInputWithdrawal(BaseModel):
136
+ model_config = _camel
137
+
138
+ status: str
139
+ amount_out_formatted: str
140
+ amount_out_usd: str
141
+ amount_out: str
142
+ withdraw_fee_formatted: str
143
+ withdraw_fee: str
144
+ withdraw_fee_usd: str
145
+ timestamp: str
146
+ hash: str
147
+
148
+
149
+ class AnyInputWithdrawalsResponse(BaseModel):
150
+ model_config = _camel
151
+
152
+ asset: str
153
+ recipient: str
154
+ affiliate_recipient: str
155
+ withdrawals: AnyInputWithdrawal | None = None
156
+
157
+
158
+ class SubmitDepositRequest(BaseModel):
159
+ model_config = _camel
160
+
161
+ tx_hash: str
162
+ deposit_address: str
163
+ near_sender_account: str | None = None
164
+ memo: str | None = None
165
+
166
+ def to_api_dict(self) -> dict:
167
+ return self.model_dump(by_alias=True, exclude_none=True)
168
+
169
+
170
+ class SubmitDepositResponse(BaseModel):
171
+ model_config = _camel
172
+
173
+ correlation_id: str
174
+ quote_response: QuoteResponse
175
+ status: SwapStatus
176
+ updated_at: datetime
177
+ swap_details: SwapDetails
defuse/utils.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import ROUND_DOWN, Decimal
4
+
5
+ from .models import TokenResponse
6
+
7
+
8
+ def to_atomic(amount: int | float | str | Decimal, decimals: int) -> str:
9
+ """Convert a human-readable token amount to its atomic (smallest unit) string.
10
+
11
+ Uses :class:`~decimal.Decimal` internally to avoid floating-point errors.
12
+
13
+ Args:
14
+ amount: Human-readable amount, e.g. ``0.1`` or ``"0.1"``.
15
+ decimals: Number of decimals for the token (from :attr:`TokenResponse.decimals`).
16
+
17
+ Returns:
18
+ Atomic amount as a string, e.g. ``"100000000000000000"`` for 0.1 ETH.
19
+
20
+ Raises:
21
+ ValueError: If the amount is negative or has more decimal places than
22
+ the token supports.
23
+
24
+ Examples:
25
+ >>> to_atomic("0.1", 18) # 0.1 ETH → wei
26
+ '100000000000000000'
27
+ >>> to_atomic(1, 24) # 1 NEAR → yoctoNEAR
28
+ '1000000000000000000000000'
29
+ """
30
+ d = Decimal(str(amount))
31
+ if d < 0:
32
+ raise ValueError(f"Amount must be non-negative, got {amount!r}")
33
+
34
+ scaled = d * Decimal(10) ** decimals
35
+
36
+ if scaled != scaled.to_integral_value():
37
+ raise ValueError(
38
+ f"{amount!r} has more decimal places than the token supports ({decimals})"
39
+ )
40
+
41
+ return str(scaled.to_integral_value(rounding=ROUND_DOWN))
42
+
43
+
44
+ def from_atomic(amount: int | str, decimals: int) -> Decimal:
45
+ """Convert an atomic (smallest unit) token amount to a human-readable :class:`~decimal.Decimal`.
46
+
47
+ Args:
48
+ amount: Atomic amount as returned by the API, e.g. ``"100000000000000000"``.
49
+ decimals: Number of decimals for the token.
50
+
51
+ Returns:
52
+ Human-readable amount, e.g. ``Decimal("0.1")`` for 0.1 ETH.
53
+
54
+ Examples:
55
+ >>> from_atomic("100000000000000000", 18)
56
+ Decimal('0.1')
57
+ >>> from_atomic("1000000000000000000000000", 24)
58
+ Decimal('1')
59
+ """
60
+ return Decimal(str(amount)) / Decimal(10) ** decimals
61
+
62
+
63
+ def token_to_atomic(amount: int | float | str | Decimal, token: TokenResponse) -> str:
64
+ """Convert a human-readable amount to atomic units using a :class:`~defuse.models.TokenResponse`.
65
+
66
+ Convenience wrapper around :func:`to_atomic` that reads ``decimals`` directly
67
+ from the token object.
68
+
69
+ Args:
70
+ amount: Human-readable amount, e.g. ``0.1``.
71
+ token: Token metadata returned by :meth:`~defuse.client.IntentsClient.get_tokens`.
72
+
73
+ Returns:
74
+ Atomic amount string ready to pass as ``amount`` in a
75
+ :class:`~defuse.models.QuoteRequest`.
76
+
77
+ Examples:
78
+ >>> tokens = await client.get_tokens()
79
+ >>> eth = find_token(tokens, "eth", "ETH")
80
+ >>> token_to_atomic(0.1, eth)
81
+ '100000000000000000'
82
+ """
83
+ return to_atomic(amount, token.decimals)
84
+
85
+
86
+ def token_from_atomic(amount: int | str, token: TokenResponse) -> Decimal:
87
+ """Convert an atomic amount to a human-readable :class:`~decimal.Decimal` using a :class:`~defuse.models.TokenResponse`.
88
+
89
+ Args:
90
+ amount: Atomic amount string as returned by the API.
91
+ token: Token metadata returned by :meth:`~defuse.client.IntentsClient.get_tokens`.
92
+
93
+ Returns:
94
+ Human-readable :class:`~decimal.Decimal`.
95
+ """
96
+ return from_atomic(amount, token.decimals)
97
+
98
+
99
+ def pct_to_bps(percent: int | float | str | Decimal) -> int:
100
+ """Convert a percentage to basis points.
101
+
102
+ Args:
103
+ percent: Percentage value, e.g. ``1`` for 1% or ``0.5`` for 0.5%.
104
+
105
+ Returns:
106
+ Basis points as an integer, e.g. ``100`` for 1%.
107
+
108
+ Examples:
109
+ >>> pct_to_bps(1)
110
+ 100
111
+ >>> pct_to_bps(0.5)
112
+ 50
113
+ """
114
+ return int(Decimal(str(percent)) * 100)
115
+
116
+
117
+ def bps_to_pct(bps: int) -> Decimal:
118
+ """Convert basis points to a percentage.
119
+
120
+ Args:
121
+ bps: Basis points, e.g. ``100`` for 1%.
122
+
123
+ Returns:
124
+ Percentage as a :class:`~decimal.Decimal`, e.g. ``Decimal("1")`` for 100 bps.
125
+
126
+ Examples:
127
+ >>> bps_to_pct(100)
128
+ Decimal('1')
129
+ >>> bps_to_pct(50)
130
+ Decimal('0.50')
131
+ """
132
+ return Decimal(bps) / 100
133
+
134
+
135
+ def find_token(
136
+ tokens: list[TokenResponse],
137
+ blockchain: str,
138
+ symbol: str,
139
+ ) -> TokenResponse:
140
+ """Look up a token by blockchain and symbol from a token list.
141
+
142
+ Args:
143
+ tokens: Token list returned by :meth:`~defuse.client.IntentsClient.get_tokens`.
144
+ blockchain: The chain to search on, e.g. ``"eth"``, ``"arb"``, ``"sol"``.
145
+ symbol: Token symbol, e.g. ``"ETH"``, ``"USDC"``. Case-insensitive.
146
+
147
+ Returns:
148
+ The matching :class:`~defuse.models.TokenResponse`.
149
+
150
+ Raises:
151
+ LookupError: If no token matches the given blockchain and symbol.
152
+
153
+ Examples:
154
+ >>> tokens = await client.get_tokens()
155
+ >>> eth = find_token(tokens, "eth", "ETH")
156
+ >>> eth.asset_id
157
+ 'nep141:eth.omft.near'
158
+ """
159
+ match = next(
160
+ (t for t in tokens if t.blockchain == blockchain and t.symbol.upper() == symbol.upper()),
161
+ None,
162
+ )
163
+ if match is None:
164
+ raise LookupError(f"No token found for {symbol!r} on {blockchain!r}")
165
+ return match
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: defuse-py
3
+ Version: 0.1.0
4
+ Summary: Async Python wrapper for the NEAR Intents 1Click Swap API
5
+ Project-URL: Homepage, https://github.com/er/defuse.py
6
+ Project-URL: Repository, https://github.com/er/defuse.py
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 er
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Requires-Python: >=3.10
30
+ Requires-Dist: httpx>=0.27
31
+ Requires-Dist: pydantic>=2.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Requires-Dist: respx>=0.21; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # defuse.py
39
+
40
+ An async Python wrapper for the [NEAR Intents 1Click Swap API](https://1click.chaindefuser.com).
41
+
42
+ ```
43
+ pip install defuse-py
44
+ ```
45
+
46
+ ## Requirements
47
+
48
+ - Python 3.10+
49
+ - `httpx`
50
+ - `pydantic`
51
+
52
+ ## Quickstart
53
+
54
+ ```python
55
+ import asyncio
56
+ from defuse import IntentsClient, find_token
57
+
58
+ async def main():
59
+ async with IntentsClient() as client:
60
+ tokens = await client.get_tokens()
61
+ eth = find_token(tokens, "eth", "ETH")
62
+ print(eth.asset_id) # nep141:eth.omft.near
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ ## Swap flow
68
+
69
+ ```python
70
+ import asyncio
71
+ from datetime import datetime, timedelta, timezone
72
+ from defuse import (
73
+ IntentsClient,
74
+ QuoteRequest,
75
+ SwapType,
76
+ DepositType,
77
+ RecipientType,
78
+ RefundType,
79
+ SwapStatus,
80
+ find_token,
81
+ token_to_atomic,
82
+ )
83
+
84
+ async def main():
85
+ async with IntentsClient(jwt_token="your-jwt-token") as client:
86
+ tokens = await client.get_tokens()
87
+ eth = find_token(tokens, "eth", "ETH")
88
+ usdc = find_token(tokens, "arb", "USDC")
89
+
90
+ # 1. Get a quote
91
+ response = await client.get_quote(QuoteRequest(
92
+ dry=False,
93
+ swap_type=SwapType.EXACT_INPUT,
94
+ slippage_tolerance=100, # 1% — use pct_to_bps(1) for clarity
95
+ origin_asset=eth.asset_id,
96
+ destination_asset=usdc.asset_id,
97
+ deposit_type=DepositType.ORIGIN_CHAIN,
98
+ amount=token_to_atomic("0.01", eth),
99
+ refund_to="0xYourAddress",
100
+ refund_type=RefundType.ORIGIN_CHAIN,
101
+ recipient="0xYourAddress",
102
+ recipient_type=RecipientType.DESTINATION_CHAIN,
103
+ deadline=datetime.now(timezone.utc) + timedelta(hours=1),
104
+ ))
105
+
106
+ quote = response.quote
107
+ print(f"Send {quote.amount_in_formatted} ETH to {quote.deposit_address}")
108
+ if quote.deposit_memo:
109
+ print(f"Memo (required): {quote.deposit_memo}")
110
+
111
+ # 2. Send funds to quote.deposit_address, then poll for status
112
+ while True:
113
+ status = await client.get_status(quote.deposit_address, quote.deposit_memo)
114
+ print(status.status)
115
+ if status.status in {SwapStatus.SUCCESS, SwapStatus.REFUNDED, SwapStatus.FAILED}:
116
+ break
117
+ await asyncio.sleep(10)
118
+
119
+ asyncio.run(main())
120
+ ```
121
+
122
+ ## Authentication
123
+
124
+ Most endpoints require a JWT token. Pass it when constructing the client:
125
+
126
+ ```python
127
+ client = IntentsClient(jwt_token="your-jwt-token")
128
+ ```
129
+
130
+ ## API reference
131
+
132
+ ### `IntentsClient`
133
+
134
+ | Method | Description |
135
+ |---|---|
136
+ | `get_tokens()` | List all supported tokens |
137
+ | `get_quote(request)` | Request a swap quote |
138
+ | `get_status(deposit_address, deposit_memo?)` | Check swap status |
139
+ | `get_any_input_withdrawals(deposit_address, ...)` | Get withdrawals for an `ANY_INPUT` quote |
140
+ | `submit_deposit(request)` | Notify the service a deposit has been sent |
141
+
142
+ ### Utilities
143
+
144
+ | Function | Description |
145
+ |---|---|
146
+ | `find_token(tokens, blockchain, symbol)` | Look up a token by chain and symbol |
147
+ | `to_atomic(amount, decimals)` | Convert `0.1` → `"100000000000000000"` |
148
+ | `from_atomic(amount, decimals)` | Convert `"100000000000000000"` → `Decimal("0.1")` |
149
+ | `token_to_atomic(amount, token)` | `to_atomic` using a `TokenResponse` |
150
+ | `token_from_atomic(amount, token)` | `from_atomic` using a `TokenResponse` |
151
+ | `pct_to_bps(percent)` | Convert `1` (%) → `100` (bps) |
152
+ | `bps_to_pct(bps)` | Convert `100` (bps) → `Decimal("1")` (%) |
153
+
154
+ ### Swap types
155
+
156
+ | Value | Behaviour |
157
+ |---|---|
158
+ | `EXACT_INPUT` | Specify input amount, receive calculated output |
159
+ | `EXACT_OUTPUT` | Specify output amount, deposit calculated input |
160
+ | `FLEX_INPUT` | Variable input with slippage bounds |
161
+ | `ANY_INPUT` | Multiple partial deposits over time |
162
+
163
+ ### Swap statuses
164
+
165
+ `KNOWN_DEPOSIT_TX` → `PENDING_DEPOSIT` → `PROCESSING` → `SUCCESS`
166
+
167
+ Terminal states: `SUCCESS`, `REFUNDED`, `FAILED`
168
+
169
+ ## Examples
170
+
171
+ See the [`examples/`](examples/) directory:
172
+
173
+ - [`list_tokens.py`](examples/list_tokens.py) — fetch and display all supported tokens
174
+ - [`dry_quote.py`](examples/dry_quote.py) — preview a swap without creating a deposit address
175
+ - [`swap.py`](examples/swap.py) — full swap lifecycle with status polling
176
+ - [`submit_deposit.py`](examples/submit_deposit.py) — notify the service after sending funds
177
+ - [`any_input_withdrawals.py`](examples/any_input_withdrawals.py) — fetch `ANY_INPUT` withdrawals
178
+
179
+ ## License
180
+
181
+ MIT
@@ -0,0 +1,10 @@
1
+ defuse/__init__.py,sha256=1Pxi2rb55oufs9nqLXbaWYR8PQdDEb8lRbMNq608BDE,1095
2
+ defuse/client.py,sha256=mg1zfKUtAeqxX8OTDB8Iga7tQ7qzMgSDPhOwWCUg2pQ,7040
3
+ defuse/enums.py,sha256=RKUuJz_Pj5o1GKF_yA27LWnZQy_qXBBSnwTjJJKnDpY,825
4
+ defuse/exceptions.py,sha256=i3saOVsjB29plEswP-h9-A1eBroFUpcFXoNn0m-89s4,484
5
+ defuse/models.py,sha256=_1Vq0E6I9obdVox35kZJVYxfk5Y0VVcMxhY743rOWFk,4573
6
+ defuse/utils.py,sha256=97Zwy6rsgRHVluEmg9qm04cxm-bYjKkMZvddcRQpVXA,5070
7
+ defuse_py-0.1.0.dist-info/METADATA,sha256=rELrr7JVfd8dIBrL4a_kU8TjIIrFi7qpBE8vFp6M9Hg,6173
8
+ defuse_py-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ defuse_py-0.1.0.dist-info/licenses/LICENSE,sha256=BfbU7PJPyRC29_p0Le5zNX6Ui-UXrl2EACs57O4cNxQ,1059
10
+ defuse_py-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) 2026 er
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.