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 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
@@ -0,0 +1,4 @@
1
+ """Package version — kept in sync with pyproject.toml by tests/test_version.py."""
2
+
3
+ VERSION = "0.1.0"
4
+ USER_AGENT = f"cryptolike-sdk-python/{VERSION}"
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()}