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 +44 -0
- defuse/client.py +208 -0
- defuse/enums.py +40 -0
- defuse/exceptions.py +15 -0
- defuse/models.py +177 -0
- defuse/utils.py +165 -0
- defuse_py-0.1.0.dist-info/METADATA +181 -0
- defuse_py-0.1.0.dist-info/RECORD +10 -0
- defuse_py-0.1.0.dist-info/WHEEL +4 -0
- defuse_py-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|