cryptolike 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.
- cryptolike/__init__.py +87 -0
- cryptolike/_version.py +4 -0
- cryptolike/auth.py +32 -0
- cryptolike/client.py +528 -0
- cryptolike/errors.py +74 -0
- cryptolike/py.typed +0 -0
- cryptolike/types.py +402 -0
- cryptolike/webhook.py +116 -0
- cryptolike-0.1.0.dist-info/METADATA +184 -0
- cryptolike-0.1.0.dist-info/RECORD +13 -0
- cryptolike-0.1.0.dist-info/WHEEL +5 -0
- cryptolike-0.1.0.dist-info/licenses/LICENSE +21 -0
- cryptolike-0.1.0.dist-info/top_level.txt +1 -0
cryptolike/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""cryptolike — official server-side SDK for the CryptoLike merchant API.
|
|
2
|
+
|
|
3
|
+
from cryptolike import Client, verify_webhook
|
|
4
|
+
|
|
5
|
+
api = Client() # CRYPTOLIKE_API_KEY_ID + CRYPTOLIKE_API_SECRET from the environment
|
|
6
|
+
invoice = api.invoices.create(amount="10.50", currency="USD", order_id="A-1001")
|
|
7
|
+
|
|
8
|
+
The API secret must stay on your server (https://cryptolike.net/docs/widget §1).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._version import USER_AGENT, VERSION
|
|
12
|
+
from .auth import body_sha256, canonical_string, sign
|
|
13
|
+
from .client import Client
|
|
14
|
+
from .errors import WITHDRAW_PENDING_APPROVAL, ApiError, CryptoLikeError, NetworkError, WebhookVerificationError
|
|
15
|
+
from .types import (
|
|
16
|
+
Balance,
|
|
17
|
+
Currency,
|
|
18
|
+
EmbedSettings,
|
|
19
|
+
Invoice,
|
|
20
|
+
InvoiceEvent,
|
|
21
|
+
Page,
|
|
22
|
+
Rate,
|
|
23
|
+
StaticAddress,
|
|
24
|
+
StaticAddressArchivedData,
|
|
25
|
+
StaticAddressArchiveWarningData,
|
|
26
|
+
StaticAddressCard,
|
|
27
|
+
StaticAddressRestoreDeposit,
|
|
28
|
+
StaticAddressRestoredData,
|
|
29
|
+
Transaction,
|
|
30
|
+
WebhookDelivery,
|
|
31
|
+
WebhookEndpoint,
|
|
32
|
+
WebhookEndpointCreated,
|
|
33
|
+
WebhookStaticAddress,
|
|
34
|
+
Withdrawal,
|
|
35
|
+
WithdrawalEstimate,
|
|
36
|
+
)
|
|
37
|
+
from .webhook import (
|
|
38
|
+
EVENT_DATA_TYPES,
|
|
39
|
+
STATIC_ADDRESS_ARCHIVE_WARNING,
|
|
40
|
+
STATIC_ADDRESS_ARCHIVED,
|
|
41
|
+
STATIC_ADDRESS_RESTORED,
|
|
42
|
+
WebhookEvent,
|
|
43
|
+
verify_webhook,
|
|
44
|
+
webhook_signature,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__version__ = VERSION
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Client",
|
|
50
|
+
"ApiError",
|
|
51
|
+
"WITHDRAW_PENDING_APPROVAL",
|
|
52
|
+
"CryptoLikeError",
|
|
53
|
+
"NetworkError",
|
|
54
|
+
"WebhookVerificationError",
|
|
55
|
+
"WebhookEvent",
|
|
56
|
+
"verify_webhook",
|
|
57
|
+
"webhook_signature",
|
|
58
|
+
"EVENT_DATA_TYPES",
|
|
59
|
+
"STATIC_ADDRESS_ARCHIVED",
|
|
60
|
+
"STATIC_ADDRESS_RESTORED",
|
|
61
|
+
"STATIC_ADDRESS_ARCHIVE_WARNING",
|
|
62
|
+
"sign",
|
|
63
|
+
"canonical_string",
|
|
64
|
+
"body_sha256",
|
|
65
|
+
"Balance",
|
|
66
|
+
"Currency",
|
|
67
|
+
"EmbedSettings",
|
|
68
|
+
"Invoice",
|
|
69
|
+
"InvoiceEvent",
|
|
70
|
+
"Page",
|
|
71
|
+
"Rate",
|
|
72
|
+
"StaticAddress",
|
|
73
|
+
"StaticAddressCard",
|
|
74
|
+
"StaticAddressArchivedData",
|
|
75
|
+
"StaticAddressRestoredData",
|
|
76
|
+
"StaticAddressArchiveWarningData",
|
|
77
|
+
"StaticAddressRestoreDeposit",
|
|
78
|
+
"WebhookStaticAddress",
|
|
79
|
+
"Transaction",
|
|
80
|
+
"WebhookDelivery",
|
|
81
|
+
"WebhookEndpoint",
|
|
82
|
+
"WebhookEndpointCreated",
|
|
83
|
+
"Withdrawal",
|
|
84
|
+
"WithdrawalEstimate",
|
|
85
|
+
"VERSION",
|
|
86
|
+
"USER_AGENT",
|
|
87
|
+
]
|
cryptolike/_version.py
ADDED
cryptolike/auth.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Request signing (https://cryptolike.net/docs/auth §3). Runs on your server only — the secret never leaves it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
Bytes = Union[bytes, bytearray, memoryview]
|
|
10
|
+
|
|
11
|
+
EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _to_bytes(body: Union[str, Bytes]) -> bytes:
|
|
15
|
+
return body.encode("utf-8") if isinstance(body, str) else bytes(body)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def body_sha256(body: Union[str, Bytes] = b"") -> str:
|
|
19
|
+
"""Lower-case hex SHA-256 of the raw body bytes (a string is taken as UTF-8)."""
|
|
20
|
+
data = _to_bytes(body)
|
|
21
|
+
return EMPTY_SHA256 if not data else hashlib.sha256(data).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical_string(timestamp: Union[int, str], method: str, path_query: str, body: Union[str, Bytes] = b"") -> str:
|
|
25
|
+
"""Four lines joined with LF, no trailing newline. ``path_query`` is the target exactly as sent."""
|
|
26
|
+
return "\n".join([str(timestamp), method.upper(), path_query, body_sha256(body)])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sign(secret: str, timestamp: Union[int, str], method: str, path_query: str, body: Union[str, Bytes] = b"") -> str:
|
|
30
|
+
"""``X-Signature``: hex HMAC-SHA256 of the canonical string, keyed with the secret as shown in the cabinet."""
|
|
31
|
+
canonical = canonical_string(timestamp, method, path_query, body)
|
|
32
|
+
return hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
|
cryptolike/client.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""Server-side client of the CryptoLike merchant API.
|
|
2
|
+
|
|
3
|
+
The API secret signs requests *as you*; keep it in your environment or secret manager and use
|
|
4
|
+
this client from your backend only — never from code that ships to a browser or a mobile app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import Any, Callable, Iterator, Mapping, Optional, Sequence, TypeVar, Union
|
|
16
|
+
from urllib.parse import quote, urlencode
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from ._version import USER_AGENT
|
|
21
|
+
from .auth import sign
|
|
22
|
+
from .errors import ApiError, NetworkError
|
|
23
|
+
from .types import (
|
|
24
|
+
Balance,
|
|
25
|
+
Currency,
|
|
26
|
+
EmbedSettings,
|
|
27
|
+
Invoice,
|
|
28
|
+
Model,
|
|
29
|
+
Page,
|
|
30
|
+
Rate,
|
|
31
|
+
StaticAddress,
|
|
32
|
+
StaticAddressCard,
|
|
33
|
+
Transaction,
|
|
34
|
+
WebhookDelivery,
|
|
35
|
+
WebhookEndpoint,
|
|
36
|
+
WebhookEndpointCreated,
|
|
37
|
+
Withdrawal,
|
|
38
|
+
WithdrawalEstimate,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_BASE_URL = "https://api.cryptolike.net"
|
|
42
|
+
# Lower-case names a caller-supplied header may never replace: they are the signature itself.
|
|
43
|
+
_SIGNED_HEADERS = frozenset({"x-api-key", "x-timestamp", "x-signature"})
|
|
44
|
+
|
|
45
|
+
Amount = Union[str, int, Decimal]
|
|
46
|
+
M = TypeVar("M", bound=Model)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _amount(value: Amount, name: str = "amount") -> str:
|
|
50
|
+
"""Decimal string for the wire. Floats are refused: they cannot represent money exactly."""
|
|
51
|
+
if isinstance(value, bool) or isinstance(value, float):
|
|
52
|
+
raise TypeError(f"{name}: use a str or Decimal, not {type(value).__name__} (floats are inexact)")
|
|
53
|
+
if isinstance(value, Decimal):
|
|
54
|
+
return format(value, "f")
|
|
55
|
+
if isinstance(value, int):
|
|
56
|
+
return str(value)
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
return value
|
|
59
|
+
raise TypeError(f"{name}: unsupported type {type(value).__name__}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _path(value: str) -> str:
|
|
63
|
+
"""Percent-escapes an id inside a path segment (as encodeURIComponent / url.PathEscape do).
|
|
64
|
+
|
|
65
|
+
Without it an id carrying ``?``, ``/`` or a space would change the request target the server
|
|
66
|
+
sees while the signature still verifies, or be normalised by urllib after signing.
|
|
67
|
+
"""
|
|
68
|
+
return quote(str(value), safe="")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _clean(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
72
|
+
out: dict[str, Any] = {}
|
|
73
|
+
for k, v in params.items():
|
|
74
|
+
if v is None:
|
|
75
|
+
continue
|
|
76
|
+
if isinstance(v, Decimal):
|
|
77
|
+
v = format(v, "f")
|
|
78
|
+
elif isinstance(v, bool):
|
|
79
|
+
v = "true" if v else "false"
|
|
80
|
+
out[k] = v
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Client:
|
|
85
|
+
"""One instance per API key; safe to share between threads (``requests.Session`` is).
|
|
86
|
+
|
|
87
|
+
>>> api = Client() # CRYPTOLIKE_API_KEY_ID + CRYPTOLIKE_API_SECRET from the environment
|
|
88
|
+
>>> inv = api.invoices.create(amount="10.50", currency="USD", order_id="A-1001")
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
key_id: Optional[str] = None,
|
|
94
|
+
secret: Optional[str] = None,
|
|
95
|
+
*,
|
|
96
|
+
base_url: Optional[str] = None,
|
|
97
|
+
session: Optional[requests.Session] = None,
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
max_retries: int = 3,
|
|
100
|
+
retry_base: float = 0.5,
|
|
101
|
+
retry_max: float = 8.0,
|
|
102
|
+
retry_after_max: float = 30.0,
|
|
103
|
+
api_version: str = "v1",
|
|
104
|
+
app_info: Optional[str] = None,
|
|
105
|
+
now: Optional[Callable[[], float]] = None,
|
|
106
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
107
|
+
idempotency_key: Optional[Callable[[], str]] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
key_id = key_id or os.environ.get("CRYPTOLIKE_API_KEY_ID")
|
|
110
|
+
secret = secret or os.environ.get("CRYPTOLIKE_API_SECRET")
|
|
111
|
+
if not key_id:
|
|
112
|
+
raise ValueError("cryptolike: key_id is required (argument or env CRYPTOLIKE_API_KEY_ID)")
|
|
113
|
+
if not secret:
|
|
114
|
+
raise ValueError("cryptolike: secret is required (argument or env CRYPTOLIKE_API_SECRET)")
|
|
115
|
+
self._key_id = key_id
|
|
116
|
+
self._secret = secret
|
|
117
|
+
self.base_url = (base_url or os.environ.get("CRYPTOLIKE_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
118
|
+
self._session = session or requests.Session()
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
self.max_retries = max_retries
|
|
121
|
+
self.retry_base = retry_base
|
|
122
|
+
self.retry_max = retry_max
|
|
123
|
+
self.retry_after_max = retry_after_max
|
|
124
|
+
self.api_version = api_version
|
|
125
|
+
self.user_agent = f"{USER_AGENT} {app_info}" if app_info else USER_AGENT
|
|
126
|
+
self._now = now or time.time
|
|
127
|
+
self._sleep = sleep or time.sleep
|
|
128
|
+
self._new_idempotency_key = idempotency_key or (lambda: str(uuid.uuid4()))
|
|
129
|
+
|
|
130
|
+
self.invoices = Invoices(self)
|
|
131
|
+
self.addresses = Addresses(self)
|
|
132
|
+
self.transactions = Transactions(self)
|
|
133
|
+
self.balances = Balances(self)
|
|
134
|
+
self.currencies = Currencies(self)
|
|
135
|
+
self.rates = Rates(self)
|
|
136
|
+
self.withdrawals = Withdrawals(self)
|
|
137
|
+
self.webhooks = Webhooks(self)
|
|
138
|
+
self.embed = Embed(self)
|
|
139
|
+
|
|
140
|
+
def __repr__(self) -> str:
|
|
141
|
+
return f"Client(key_id={self._key_id!r}, base_url={self.base_url!r}, secret='[redacted]')"
|
|
142
|
+
|
|
143
|
+
# ---- static-address archive (the same calls as ``addresses.*``, named after the feature) ---
|
|
144
|
+
|
|
145
|
+
def archive_static_address(self, address_id: str, *, idempotency_key: Optional[str] = None) -> StaticAddress:
|
|
146
|
+
"""Archive a static address (``POST /v1/addresses/{id}/archive``): no longer offered, out of
|
|
147
|
+
real-time scanning, never deleted or re-issued. Archived addresses are checked for incoming
|
|
148
|
+
transfers regularly (every 24 h by default); a transfer found is credited as usual and
|
|
149
|
+
restores the address automatically (webhook ``static_address.restored``, reason
|
|
150
|
+
``deposit``). Archiving an archived address is a no-op. Webhook ``static_address.archived``."""
|
|
151
|
+
return self.addresses.archive(address_id, idempotency_key=idempotency_key)
|
|
152
|
+
|
|
153
|
+
def restore_static_address(self, address_id: str, *, idempotency_key: Optional[str] = None) -> StaticAddress:
|
|
154
|
+
"""Restore an archived static address (``POST /v1/addresses/{id}/restore``); restoring an
|
|
155
|
+
active one is a no-op. ``ApiError`` ``409 conflict`` with ``details["active_address_id"]``
|
|
156
|
+
when the customer already has another active address for the currency — archive that one
|
|
157
|
+
first."""
|
|
158
|
+
return self.addresses.restore(address_id, idempotency_key=idempotency_key)
|
|
159
|
+
|
|
160
|
+
def list_archived_static_addresses(self, **filters: Any) -> Page[StaticAddress]:
|
|
161
|
+
"""One page of archived static addresses (``status=archived``), newest first. Filters:
|
|
162
|
+
search, coin_id, customer_id, limit, cursor."""
|
|
163
|
+
return self.addresses.list(**{**filters, "status": "archived"})
|
|
164
|
+
|
|
165
|
+
def iterate_archived_static_addresses(self, **filters: Any) -> Iterator[StaticAddress]:
|
|
166
|
+
"""Every archived static address matching the filters, page by page."""
|
|
167
|
+
return self.addresses.iterate(**{**filters, "status": "archived"})
|
|
168
|
+
|
|
169
|
+
# ---- transport ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def request(
|
|
172
|
+
self,
|
|
173
|
+
method: str,
|
|
174
|
+
path: str,
|
|
175
|
+
*,
|
|
176
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
177
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
178
|
+
idempotency_key: Optional[str] = None,
|
|
179
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
180
|
+
raw: bool = False,
|
|
181
|
+
) -> Any:
|
|
182
|
+
"""Signed request with retries. Returns the parsed JSON (``raw=True``: the response text).
|
|
183
|
+
|
|
184
|
+
Prefer the typed resources; use this for an endpoint added after this SDK version.
|
|
185
|
+
"""
|
|
186
|
+
method = method.upper()
|
|
187
|
+
qs = urlencode(_clean(query or {}))
|
|
188
|
+
path_query = f"{path}?{qs}" if qs else path
|
|
189
|
+
data = b"" if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
190
|
+
idem = idempotency_key or (self._new_idempotency_key() if method == "POST" else None)
|
|
191
|
+
|
|
192
|
+
attempt = 0
|
|
193
|
+
last_ts: Optional[int] = None
|
|
194
|
+
while True:
|
|
195
|
+
ts = int(self._now())
|
|
196
|
+
if ts == last_ts:
|
|
197
|
+
# Timestamps are whole seconds, so a retry inside the same second would repeat the
|
|
198
|
+
# signature byte for byte and the server accepts a signature once: a retry after a
|
|
199
|
+
# genuine 5xx would come back as ``401 auth.replay``. Wait the second out first.
|
|
200
|
+
self._sleep(_until_next_second(self._now()))
|
|
201
|
+
ts = int(self._now())
|
|
202
|
+
last_ts = ts
|
|
203
|
+
h: dict[str, str] = {
|
|
204
|
+
"Accept": "text/csv" if raw else "application/json",
|
|
205
|
+
"User-Agent": self.user_agent,
|
|
206
|
+
"X-API-Version": self.api_version,
|
|
207
|
+
}
|
|
208
|
+
# Caller headers first: they may refine Accept, never the signed ones.
|
|
209
|
+
if headers:
|
|
210
|
+
h.update({k: v for k, v in headers.items() if k.lower() not in _SIGNED_HEADERS})
|
|
211
|
+
h["X-API-Key"] = self._key_id
|
|
212
|
+
h["X-Timestamp"] = str(ts)
|
|
213
|
+
h["X-Signature"] = sign(self._secret, ts, method, path_query, data)
|
|
214
|
+
if data:
|
|
215
|
+
h["Content-Type"] = "application/json"
|
|
216
|
+
if idem:
|
|
217
|
+
h["Idempotency-Key"] = idem
|
|
218
|
+
try:
|
|
219
|
+
res = self._session.request(method, self.base_url + path_query, headers=h, data=data or None, timeout=self.timeout, allow_redirects=False)
|
|
220
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
|
221
|
+
if attempt >= self.max_retries:
|
|
222
|
+
raise NetworkError(f"{method} {path_query}: {e.__class__.__name__}", e) from e
|
|
223
|
+
self._sleep(self._backoff(attempt))
|
|
224
|
+
attempt += 1
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if 200 <= res.status_code < 300:
|
|
228
|
+
if not res.content:
|
|
229
|
+
return None
|
|
230
|
+
ctype = res.headers.get("Content-Type", "")
|
|
231
|
+
return res.json() if (not raw and "application/json" in ctype) else res.text
|
|
232
|
+
|
|
233
|
+
retry_after = _retry_after(res.headers.get("Retry-After"))
|
|
234
|
+
err = ApiError(res.status_code, _json_or_none(res), retry_after=retry_after, request_id=res.headers.get("X-Request-Id"))
|
|
235
|
+
if err.status == 401 and err.code == "auth.replay" and attempt < self.max_retries:
|
|
236
|
+
# Timestamps are whole seconds: an identical request within the same second carries
|
|
237
|
+
# the same signature and the server accepts a signature once. Wait for the next
|
|
238
|
+
# second and sign afresh (POSTs keep their Idempotency-Key: nothing is created twice).
|
|
239
|
+
self._sleep(_until_next_second(self._now()))
|
|
240
|
+
attempt += 1
|
|
241
|
+
continue
|
|
242
|
+
if not _retryable(res.status_code) or attempt >= self.max_retries:
|
|
243
|
+
raise err
|
|
244
|
+
delay = retry_after if retry_after is not None else self._backoff(attempt)
|
|
245
|
+
if delay > self.retry_after_max:
|
|
246
|
+
raise err
|
|
247
|
+
self._sleep(delay)
|
|
248
|
+
attempt += 1
|
|
249
|
+
|
|
250
|
+
def _backoff(self, attempt: int) -> float:
|
|
251
|
+
cap = min(self.retry_max, self.retry_base * (2**attempt))
|
|
252
|
+
return random.uniform(0, cap)
|
|
253
|
+
|
|
254
|
+
def _paginate(self, item_type: type[M], path: str, query: dict[str, Any]) -> Iterator[M]:
|
|
255
|
+
cursor: Optional[str] = None
|
|
256
|
+
while True:
|
|
257
|
+
page = Page.of(item_type, self.request("GET", path, query={**query, "cursor": cursor}))
|
|
258
|
+
yield from page.items
|
|
259
|
+
if not page.next_cursor:
|
|
260
|
+
return
|
|
261
|
+
cursor = page.next_cursor
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _until_next_second(now: float) -> float:
|
|
265
|
+
"""Seconds until the clock crosses into the next whole second (+10 ms of margin)."""
|
|
266
|
+
return 1.0 - (now % 1.0) + 0.01
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _retryable(status: int) -> bool:
|
|
270
|
+
return status == 429 or (status >= 500 and status != 501)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
274
|
+
if not value:
|
|
275
|
+
return None
|
|
276
|
+
try:
|
|
277
|
+
n = float(value)
|
|
278
|
+
except ValueError:
|
|
279
|
+
return None
|
|
280
|
+
return n if n >= 0 else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _json_or_none(res: requests.Response) -> Optional[dict[str, Any]]:
|
|
284
|
+
if "application/json" not in res.headers.get("Content-Type", ""):
|
|
285
|
+
return None
|
|
286
|
+
try:
|
|
287
|
+
out = res.json()
|
|
288
|
+
except ValueError:
|
|
289
|
+
return None
|
|
290
|
+
return out if isinstance(out, dict) else None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---- resources -------------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Resource:
|
|
297
|
+
def __init__(self, client: Client) -> None:
|
|
298
|
+
self._c = client
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Currencies(_Resource):
|
|
302
|
+
def list(self) -> list[Currency]:
|
|
303
|
+
"""Coins this key's merchant can use (``read``)."""
|
|
304
|
+
return [Currency.from_dict(d) for d in self._c.request("GET", "/v1/currencies").get("currencies", [])]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Rates(_Resource):
|
|
308
|
+
def list(self, fiat: Optional[str] = None) -> list[Rate]:
|
|
309
|
+
"""Latest quote of every enabled coin against ``fiat`` (default: the merchant's default fiat)."""
|
|
310
|
+
return [Rate.from_dict(d) for d in self._c.request("GET", "/v1/rates", query={"fiat": fiat}).get("rates", [])]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class Balances(_Resource):
|
|
314
|
+
def list(self) -> list[Balance]:
|
|
315
|
+
"""Available / pending / on-hold balance per coin."""
|
|
316
|
+
return [Balance.from_dict(d) for d in self._c.request("GET", "/v1/balances").get("balances", [])]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Invoices(_Resource):
|
|
320
|
+
def create(
|
|
321
|
+
self,
|
|
322
|
+
amount: Amount,
|
|
323
|
+
currency: str,
|
|
324
|
+
*,
|
|
325
|
+
coin_id: Optional[str] = None,
|
|
326
|
+
description: Optional[str] = None,
|
|
327
|
+
order_id: Optional[str] = None,
|
|
328
|
+
ttl_seconds: Optional[int] = None,
|
|
329
|
+
return_url: Optional[str] = None,
|
|
330
|
+
webhook_url: Optional[str] = None,
|
|
331
|
+
fee_paid_by: Optional[str] = None,
|
|
332
|
+
metadata: Optional[dict[str, str]] = None,
|
|
333
|
+
idempotency_by_order_id: Optional[bool] = None,
|
|
334
|
+
allowed_coin_ids: Optional[Sequence[str]] = None,
|
|
335
|
+
idempotency_key: Optional[str] = None,
|
|
336
|
+
) -> Invoice:
|
|
337
|
+
"""Create a payment; send the payer to ``checkout_url``. ``idempotency_key`` defaults to a random UUID.
|
|
338
|
+
|
|
339
|
+
``metadata``: up to 20 string pairs, returned in the API and webhooks, never shown to the payer.
|
|
340
|
+
``idempotency_by_order_id=True`` (needs ``order_id``): the open invoice of the same order is
|
|
341
|
+
returned instead of a new one; a different amount or an order that already received funds is
|
|
342
|
+
``409 conflict`` (``details.reason``: ``order_invoice_mismatch`` / ``order_has_payment``).
|
|
343
|
+
``allowed_coin_ids`` (``Currency.id``, 1..100, not with ``coin_id``): the coins the payer may
|
|
344
|
+
choose from on the payment page; ``None`` — every coin you offer for invoices. The server
|
|
345
|
+
keeps the ones offered for invoices that match ``currency``; none left is
|
|
346
|
+
``422 no_allowed_coins``. An empty list raises ``ValueError`` before the request (a filter
|
|
347
|
+
that found nothing never turns into "every coin").
|
|
348
|
+
"""
|
|
349
|
+
if allowed_coin_ids is not None:
|
|
350
|
+
if isinstance(allowed_coin_ids, str):
|
|
351
|
+
raise TypeError("allowed_coin_ids must be a list of coin ids, not a string")
|
|
352
|
+
allowed_coin_ids = list(allowed_coin_ids)
|
|
353
|
+
if not allowed_coin_ids:
|
|
354
|
+
raise ValueError("allowed_coin_ids is empty — pass None to offer every coin you offer for invoices")
|
|
355
|
+
body = _clean(
|
|
356
|
+
{
|
|
357
|
+
"amount": _amount(amount),
|
|
358
|
+
"currency": currency,
|
|
359
|
+
"coin_id": coin_id,
|
|
360
|
+
"description": description,
|
|
361
|
+
"order_id": order_id,
|
|
362
|
+
"ttl_seconds": ttl_seconds,
|
|
363
|
+
"return_url": return_url,
|
|
364
|
+
"webhook_url": webhook_url,
|
|
365
|
+
"fee_paid_by": fee_paid_by,
|
|
366
|
+
"metadata": metadata,
|
|
367
|
+
"idempotency_by_order_id": idempotency_by_order_id,
|
|
368
|
+
"allowed_coin_ids": allowed_coin_ids,
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
return Invoice.from_dict(self._c.request("POST", "/v1/invoices", body=body, idempotency_key=idempotency_key))
|
|
372
|
+
|
|
373
|
+
def get(self, invoice_id: str) -> Invoice:
|
|
374
|
+
"""The invoice with its status timeline (``events``)."""
|
|
375
|
+
return Invoice.from_dict(self._c.request("GET", f"/v1/invoices/{_path(invoice_id)}"))
|
|
376
|
+
|
|
377
|
+
def list(self, **filters: Any) -> Page[Invoice]:
|
|
378
|
+
"""One page, newest first. Filters: status, coin_id, from_, to, min_amount, max_amount, order_id, search, limit, cursor."""
|
|
379
|
+
return Page.of(Invoice, self._c.request("GET", "/v1/invoices", query=_filters(filters)))
|
|
380
|
+
|
|
381
|
+
def iterate(self, **filters: Any) -> Iterator[Invoice]:
|
|
382
|
+
"""Every invoice matching the filters, page by page."""
|
|
383
|
+
return self._c._paginate(Invoice, "/v1/invoices", _filters(filters))
|
|
384
|
+
|
|
385
|
+
def cancel(self, invoice_id: str, *, idempotency_key: Optional[str] = None) -> Invoice:
|
|
386
|
+
"""Only status ``new`` can be cancelled (otherwise ``409 conflict``)."""
|
|
387
|
+
return Invoice.from_dict(self._c.request("POST", f"/v1/invoices/{_path(invoice_id)}/cancel", idempotency_key=idempotency_key))
|
|
388
|
+
|
|
389
|
+
def export_csv(self, **filters: Any) -> str:
|
|
390
|
+
"""The whole filtered list as CSV text."""
|
|
391
|
+
return self._c.request("GET", "/v1/invoices", query={**_filters(filters), "format": "csv"}, raw=True)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class Addresses(_Resource):
|
|
395
|
+
def create(self, coin_id: str, customer_id: str, *, label: Optional[str] = None, idempotency_key: Optional[str] = None) -> StaticAddress:
|
|
396
|
+
"""One active address per (coin, customer); a second request is ``409``."""
|
|
397
|
+
body = _clean({"coin_id": coin_id, "customer_id": customer_id, "label": label})
|
|
398
|
+
return StaticAddress.from_dict(self._c.request("POST", "/v1/addresses", body=body, idempotency_key=idempotency_key))
|
|
399
|
+
|
|
400
|
+
def get(self, address_id: str, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> StaticAddressCard:
|
|
401
|
+
"""The address with one page of its deposit history."""
|
|
402
|
+
return StaticAddressCard.from_dict(self._c.request("GET", f"/v1/addresses/{_path(address_id)}", query={"limit": limit, "cursor": cursor}))
|
|
403
|
+
|
|
404
|
+
def list(self, **filters: Any) -> Page[StaticAddress]:
|
|
405
|
+
return Page.of(StaticAddress, self._c.request("GET", "/v1/addresses", query=_filters(filters)))
|
|
406
|
+
|
|
407
|
+
def iterate(self, **filters: Any) -> Iterator[StaticAddress]:
|
|
408
|
+
return self._c._paginate(StaticAddress, "/v1/addresses", _filters(filters))
|
|
409
|
+
|
|
410
|
+
def archive(self, address_id: str, *, idempotency_key: Optional[str] = None) -> StaticAddress:
|
|
411
|
+
"""Stops offering the address. Archived addresses are still checked for incoming transfers
|
|
412
|
+
regularly; a transfer is credited and restores the address automatically (webhook
|
|
413
|
+
``static_address.restored``). Archiving an archived address is a no-op."""
|
|
414
|
+
return StaticAddress.from_dict(self._c.request("POST", f"/v1/addresses/{_path(address_id)}/archive", idempotency_key=idempotency_key))
|
|
415
|
+
|
|
416
|
+
def restore(self, address_id: str, *, idempotency_key: Optional[str] = None) -> StaticAddress:
|
|
417
|
+
"""Makes an archived address active again (restoring an active one is a no-op).
|
|
418
|
+
``ApiError`` ``409 conflict`` with ``details["active_address_id"]`` when the customer already
|
|
419
|
+
has another active address for the currency — archive that one first."""
|
|
420
|
+
return StaticAddress.from_dict(self._c.request("POST", f"/v1/addresses/{_path(address_id)}/restore", idempotency_key=idempotency_key))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class Transactions(_Resource):
|
|
424
|
+
def list(self, **filters: Any) -> Page[Transaction]:
|
|
425
|
+
return Page.of(Transaction, self._c.request("GET", "/v1/transactions", query=_filters(filters)))
|
|
426
|
+
|
|
427
|
+
def iterate(self, **filters: Any) -> Iterator[Transaction]:
|
|
428
|
+
return self._c._paginate(Transaction, "/v1/transactions", _filters(filters))
|
|
429
|
+
|
|
430
|
+
def get(self, transaction_id: str) -> Transaction:
|
|
431
|
+
return Transaction.from_dict(self._c.request("GET", f"/v1/transactions/{_path(transaction_id)}"))
|
|
432
|
+
|
|
433
|
+
def export_csv(self, **filters: Any) -> str:
|
|
434
|
+
"""The whole filtered list as CSV text (amounts as exact decimal text)."""
|
|
435
|
+
return self._c.request("GET", "/v1/transactions", query={**_filters(filters), "format": "csv"}, raw=True)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class Withdrawals(_Resource):
|
|
439
|
+
def estimate(self, coin_id: str, amount: Amount, *, to_address: Optional[str] = None) -> WithdrawalEstimate:
|
|
440
|
+
"""Fees and the total debit for ``amount`` of a coin — nothing is created."""
|
|
441
|
+
body = _clean({"coin_id": coin_id, "amount": _amount(amount), "to_address": to_address})
|
|
442
|
+
return WithdrawalEstimate.from_dict(self._c.request("POST", "/v1/withdrawals/estimate", body=body))
|
|
443
|
+
|
|
444
|
+
def create(
|
|
445
|
+
self,
|
|
446
|
+
coin_id: str,
|
|
447
|
+
amount: Amount,
|
|
448
|
+
to_address: str,
|
|
449
|
+
*,
|
|
450
|
+
payout_id: Optional[str] = None,
|
|
451
|
+
comment: Optional[str] = None,
|
|
452
|
+
idempotency_key: Optional[str] = None,
|
|
453
|
+
) -> Withdrawal:
|
|
454
|
+
"""Queue a payout (``withdraw``; verified merchant). Give ``payout_id`` — repeating it with the
|
|
455
|
+
same parameters returns the existing withdrawal instead of creating a second one."""
|
|
456
|
+
body = _clean({"coin_id": coin_id, "amount": _amount(amount), "to_address": to_address, "payout_id": payout_id, "comment": comment})
|
|
457
|
+
return Withdrawal.from_dict(self._c.request("POST", "/v1/withdrawals", body=body, idempotency_key=idempotency_key))
|
|
458
|
+
|
|
459
|
+
def get(self, withdrawal_id: str) -> Withdrawal:
|
|
460
|
+
return Withdrawal.from_dict(self._c.request("GET", f"/v1/withdrawals/{_path(withdrawal_id)}"))
|
|
461
|
+
|
|
462
|
+
def list(self, **filters: Any) -> Page[Withdrawal]:
|
|
463
|
+
return Page.of(Withdrawal, self._c.request("GET", "/v1/withdrawals", query=_filters(filters)))
|
|
464
|
+
|
|
465
|
+
def iterate(self, **filters: Any) -> Iterator[Withdrawal]:
|
|
466
|
+
return self._c._paginate(Withdrawal, "/v1/withdrawals", _filters(filters))
|
|
467
|
+
|
|
468
|
+
def cancel(self, withdrawal_id: str, *, idempotency_key: Optional[str] = None) -> Withdrawal:
|
|
469
|
+
"""Only before processing starts (``pending``, ``review``, ``awaiting_approval``)."""
|
|
470
|
+
return Withdrawal.from_dict(self._c.request("POST", f"/v1/withdrawals/{_path(withdrawal_id)}/cancel", idempotency_key=idempotency_key))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class WebhookEndpoints(_Resource):
|
|
474
|
+
def list(self) -> list[WebhookEndpoint]:
|
|
475
|
+
return [WebhookEndpoint.from_dict(d) for d in self._c.request("GET", "/v1/webhooks/endpoints").get("endpoints", [])]
|
|
476
|
+
|
|
477
|
+
def create(self, url: str, events: list[str], *, secret: Optional[str] = None, idempotency_key: Optional[str] = None) -> WebhookEndpointCreated:
|
|
478
|
+
"""The response carries the signing ``secret`` once — store it on your server."""
|
|
479
|
+
body = _clean({"url": url, "events": events, "secret": secret})
|
|
480
|
+
return WebhookEndpointCreated.from_dict(self._c.request("POST", "/v1/webhooks/endpoints", body=body, idempotency_key=idempotency_key))
|
|
481
|
+
|
|
482
|
+
def get(self, endpoint_id: str) -> WebhookEndpoint:
|
|
483
|
+
return WebhookEndpoint.from_dict(self._c.request("GET", f"/v1/webhooks/endpoints/{_path(endpoint_id)}"))
|
|
484
|
+
|
|
485
|
+
def update(self, endpoint_id: str, *, url: Optional[str] = None, events: Optional[list[str]] = None, enabled: Optional[bool] = None) -> WebhookEndpoint:
|
|
486
|
+
body = {k: v for k, v in {"url": url, "events": events, "enabled": enabled}.items() if v is not None}
|
|
487
|
+
return WebhookEndpoint.from_dict(self._c.request("PATCH", f"/v1/webhooks/endpoints/{_path(endpoint_id)}", body=body))
|
|
488
|
+
|
|
489
|
+
def delete(self, endpoint_id: str) -> None:
|
|
490
|
+
self._c.request("DELETE", f"/v1/webhooks/endpoints/{_path(endpoint_id)}")
|
|
491
|
+
|
|
492
|
+
def rotate_secret(self, endpoint_id: str, *, idempotency_key: Optional[str] = None) -> str:
|
|
493
|
+
"""New secret, shown once; the old one stops verifying immediately."""
|
|
494
|
+
return str(self._c.request("POST", f"/v1/webhooks/endpoints/{_path(endpoint_id)}/rotate-secret", idempotency_key=idempotency_key)["secret"])
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class WebhookDeliveries(_Resource):
|
|
498
|
+
def list(self, **filters: Any) -> Page[WebhookDelivery]:
|
|
499
|
+
return Page.of(WebhookDelivery, self._c.request("GET", "/v1/webhooks/deliveries", query=_filters(filters)))
|
|
500
|
+
|
|
501
|
+
def get(self, delivery_id: str) -> WebhookDelivery:
|
|
502
|
+
return WebhookDelivery.from_dict(self._c.request("GET", f"/v1/webhooks/deliveries/{_path(delivery_id)}"))
|
|
503
|
+
|
|
504
|
+
def retry(self, delivery_id: str, *, idempotency_key: Optional[str] = None) -> WebhookDelivery:
|
|
505
|
+
return WebhookDelivery.from_dict(self._c.request("POST", f"/v1/webhooks/deliveries/{_path(delivery_id)}/retry", idempotency_key=idempotency_key))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class Webhooks(_Resource):
|
|
509
|
+
def __init__(self, client: Client) -> None:
|
|
510
|
+
super().__init__(client)
|
|
511
|
+
self.endpoints = WebhookEndpoints(client)
|
|
512
|
+
self.deliveries = WebhookDeliveries(client)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class Embed(_Resource):
|
|
516
|
+
"""Checkout widget allowlist (https://cryptolike.net/docs/widget §2)."""
|
|
517
|
+
|
|
518
|
+
def get(self) -> EmbedSettings:
|
|
519
|
+
return EmbedSettings.from_dict(self._c.request("GET", "/v1/embed"))
|
|
520
|
+
|
|
521
|
+
def set(self, allowed_origins: list[str]) -> EmbedSettings:
|
|
522
|
+
"""Replaces the allowlist as a whole (``[]`` switches embedding off)."""
|
|
523
|
+
return EmbedSettings.from_dict(self._c.request("PUT", "/v1/embed", body={"allowed_origins": allowed_origins}))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _filters(filters: Mapping[str, Any]) -> dict[str, Any]:
|
|
527
|
+
"""``from_`` → ``from`` (a Python keyword); everything else as given."""
|
|
528
|
+
return {("from" if k == "from_" else k): v for k, v in filters.items()}
|