cryptunnel 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ day: monday
8
+ groups:
9
+ dependencies:
10
+ patterns: ['*']
11
+ update-types: [minor, patch]
12
+ - package-ecosystem: github-actions
13
+ directory: /
14
+ schedule:
15
+ interval: weekly
16
+ day: monday
@@ -0,0 +1,23 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ # Trusted publishing (OIDC): no PyPI token is stored anywhere
8
+ permissions:
9
+ id-token: write
10
+ contents: read
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.12'
21
+ - run: pip install build
22
+ - run: python -m build
23
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,20 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ pytest:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ['3.10', '3.11', '3.12', '3.13']
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e . pytest pytest-asyncio
20
+ - run: pytest -q
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package follows semantic versioning.
5
+
6
+ ## [1.0.0] - 2026-10-03
7
+
8
+ ### Added
9
+
10
+ - `Cryptunnel` async client and `CryptunnelSync` blocking mirror over `httpx`.
11
+ - Payment creation (widget and h2h), payment read and list, currency list, merchant info.
12
+ - `verify_webhook` - constant-time HMAC-SHA256 verification with a timestamp tolerance.
13
+ - `wait_for_payment` - polling with exponential backoff for scripts and development.
14
+ - Exception hierarchy mapping API status codes, keeping the raw API `code`.
15
+ - `sandbox=True` switch marking every created payment as a test payment.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cryptunnel
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.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.5
2
+ Name: cryptunnel
3
+ Version: 1.0.0
4
+ Summary: Python SDK for Cryptunnel - accept crypto payments straight to your own wallets
5
+ Project-URL: Homepage, https://cryptunnel.io
6
+ Project-URL: Documentation, https://docs.cryptunnel.io
7
+ Project-URL: Issues, https://github.com/cryptunnel/cryptunnel-python/issues
8
+ Project-URL: Source, https://github.com/cryptunnel/cryptunnel-python
9
+ Author: Cryptunnel
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: bitcoin,crypto,cryptunnel,ethereum,payments,tron,usdt
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Description-Content-Type: text/markdown
24
+
25
+ # cryptunnel
26
+
27
+ Python SDK for [Cryptunnel](https://cryptunnel.io) - accept crypto payments straight into your own
28
+ wallets. Async-first for bots on aiogram, with a blocking mirror for scripts.
29
+
30
+ ```bash
31
+ pip install cryptunnel
32
+ ```
33
+
34
+ `httpx` is the only dependency. Python 3.10+.
35
+
36
+ ## Create a sandbox payment
37
+
38
+ ```python
39
+ from cryptunnel import CryptunnelSync
40
+
41
+ cryptunnel = CryptunnelSync("<merchant id>", "<api key>", sandbox=True)
42
+
43
+ print(cryptunnel.get_merchant()) # your credentials work if this prints your merchant
44
+
45
+ payment = cryptunnel.create_widget_payment(
46
+ amount=10,
47
+ currency="USD",
48
+ external_id="order-1",
49
+ success_url="https://example.com/thanks",
50
+ )
51
+ print(payment["url"]) # send the buyer here
52
+ ```
53
+
54
+ `sandbox=True` sets `is_test` on every payment it creates: the payer is offered testnet currencies
55
+ only, and the payment is excluded from your stats and fees. It is the same host and the same key -
56
+ the sandbox is a flag, not a second account.
57
+
58
+ ## Verify a webhook
59
+
60
+ ```python
61
+ from flask import Flask, request
62
+
63
+ from cryptunnel import verify_webhook
64
+
65
+ app = Flask(__name__)
66
+ WEBHOOK_SECRET = "<whsec_...>"
67
+
68
+ @app.post("/cryptunnel")
69
+ def callback():
70
+ if not verify_webhook(WEBHOOK_SECRET, request.headers, request.get_data()):
71
+ return "", 401
72
+ payment = request.get_json()
73
+ if payment["status"] in ("confirmed", "confirmed_manual"):
74
+ deliver(payment["external_id"]) # deduplicate on (id, status): retries repeat the same pair
75
+ return "", 200
76
+ ```
77
+
78
+ Pass the raw body bytes as received - parsing and re-serialising the JSON changes the signature.
79
+ A failed delivery is retried 60 times, once a minute, for one hour, each attempt re-signed with a
80
+ fresh timestamp over the same body.
81
+
82
+ ## Async
83
+
84
+ ```python
85
+ import asyncio
86
+
87
+ from cryptunnel import Cryptunnel
88
+
89
+ async def main():
90
+ async with Cryptunnel("<merchant id>", "<api key>", sandbox=True) as cryptunnel:
91
+ currencies = await cryptunnel.list_currencies()
92
+ payment = await cryptunnel.create_h2h_payment(10, "USD", "order-2", currencies[0]["code"])
93
+ print(payment["wallet_address"], payment["amount"], payment["currency"])
94
+
95
+ asyncio.run(main())
96
+ ```
97
+
98
+ ## The whole surface
99
+
100
+ | Method | Call |
101
+ | --- | --- |
102
+ | `create_widget_payment(amount, currency, external_id, ...)` | `POST /v1/payments/widget` |
103
+ | `create_h2h_payment(amount, currency, external_id, target_currency, ...)` | `POST /v1/payments/h2h` |
104
+ | `get_payment(payment_id)` | `GET /v1/payments/{id}` |
105
+ | `list_payments(limit, offset)` | `GET /v1/payments` |
106
+ | `list_currencies()` | `GET /v1/currencies` |
107
+ | `get_merchant()` | `GET /v1/merchants` |
108
+ | `verify_webhook(secret, headers, raw_body)` | local, no request |
109
+ | `wait_for_payment(payment_id)` | polls `GET /v1/payments/{id}` |
110
+
111
+ Every method exists on both `Cryptunnel` (await it) and `CryptunnelSync` (call it).
112
+
113
+ Payment creation is idempotent on `external_id`: a retry after a network timeout returns the payment
114
+ you already created instead of a second one. Repeating an `external_id` with a different amount or
115
+ currency is rejected with `PAYMENT_ALREADY_EXISTS`.
116
+
117
+ `get_payment` returns two shapes, and the status tells them apart: a `created` or `expired` payment
118
+ carries no `amount`, `currency` or `wallet_address`, because nobody has picked a currency for it
119
+ yet. Read them with `payment.get("amount")` rather than `payment["amount"]` unless you already know
120
+ the status.
121
+
122
+ `wait_for_payment` is for scripts and development - it polls every 5 seconds, backing off to 30, and
123
+ raises `PaymentTimeoutError` after 30 minutes. In production the webhook is the guarantee: a buyer
124
+ who closes the page still produces a callback.
125
+
126
+ ## Errors
127
+
128
+ ```python
129
+ from cryptunnel import AuthenticationError, RateLimitError, ValidationError
130
+
131
+ try:
132
+ cryptunnel.create_h2h_payment(10, "USD", "order-3", "DOGE")
133
+ except ValidationError as error:
134
+ print(error.code) # WALLET_NOT_FOUND - you have no active DOGE wallet
135
+ except AuthenticationError:
136
+ print("check the merchant id and the api key")
137
+ except RateLimitError as error:
138
+ print(error.retry_after) # seconds to wait; None when the API sends no Retry-After header
139
+ ```
140
+
141
+ `CryptunnelError` is the base; `AuthenticationError` (401), `NotFoundError` (404),
142
+ `ValidationError` (400), `RateLimitError` (429) and `ApiError` (5xx and transport failures) derive
143
+ from it. The raw API `code` is always on the exception.
144
+
145
+ ## Links
146
+
147
+ - [Quickstart](https://docs.cryptunnel.io/docs/quickstart) - registration to first payment
148
+ - [Sandbox and faucets](https://docs.cryptunnel.io/docs/sandbox) - test coins without spending any
149
+ - [API reference](https://docs.cryptunnel.io)
150
+ - Support: [GitHub Issues](https://github.com/cryptunnel/cryptunnel-python/issues)
151
+
152
+ MIT licensed.
@@ -0,0 +1,128 @@
1
+ # cryptunnel
2
+
3
+ Python SDK for [Cryptunnel](https://cryptunnel.io) - accept crypto payments straight into your own
4
+ wallets. Async-first for bots on aiogram, with a blocking mirror for scripts.
5
+
6
+ ```bash
7
+ pip install cryptunnel
8
+ ```
9
+
10
+ `httpx` is the only dependency. Python 3.10+.
11
+
12
+ ## Create a sandbox payment
13
+
14
+ ```python
15
+ from cryptunnel import CryptunnelSync
16
+
17
+ cryptunnel = CryptunnelSync("<merchant id>", "<api key>", sandbox=True)
18
+
19
+ print(cryptunnel.get_merchant()) # your credentials work if this prints your merchant
20
+
21
+ payment = cryptunnel.create_widget_payment(
22
+ amount=10,
23
+ currency="USD",
24
+ external_id="order-1",
25
+ success_url="https://example.com/thanks",
26
+ )
27
+ print(payment["url"]) # send the buyer here
28
+ ```
29
+
30
+ `sandbox=True` sets `is_test` on every payment it creates: the payer is offered testnet currencies
31
+ only, and the payment is excluded from your stats and fees. It is the same host and the same key -
32
+ the sandbox is a flag, not a second account.
33
+
34
+ ## Verify a webhook
35
+
36
+ ```python
37
+ from flask import Flask, request
38
+
39
+ from cryptunnel import verify_webhook
40
+
41
+ app = Flask(__name__)
42
+ WEBHOOK_SECRET = "<whsec_...>"
43
+
44
+ @app.post("/cryptunnel")
45
+ def callback():
46
+ if not verify_webhook(WEBHOOK_SECRET, request.headers, request.get_data()):
47
+ return "", 401
48
+ payment = request.get_json()
49
+ if payment["status"] in ("confirmed", "confirmed_manual"):
50
+ deliver(payment["external_id"]) # deduplicate on (id, status): retries repeat the same pair
51
+ return "", 200
52
+ ```
53
+
54
+ Pass the raw body bytes as received - parsing and re-serialising the JSON changes the signature.
55
+ A failed delivery is retried 60 times, once a minute, for one hour, each attempt re-signed with a
56
+ fresh timestamp over the same body.
57
+
58
+ ## Async
59
+
60
+ ```python
61
+ import asyncio
62
+
63
+ from cryptunnel import Cryptunnel
64
+
65
+ async def main():
66
+ async with Cryptunnel("<merchant id>", "<api key>", sandbox=True) as cryptunnel:
67
+ currencies = await cryptunnel.list_currencies()
68
+ payment = await cryptunnel.create_h2h_payment(10, "USD", "order-2", currencies[0]["code"])
69
+ print(payment["wallet_address"], payment["amount"], payment["currency"])
70
+
71
+ asyncio.run(main())
72
+ ```
73
+
74
+ ## The whole surface
75
+
76
+ | Method | Call |
77
+ | --- | --- |
78
+ | `create_widget_payment(amount, currency, external_id, ...)` | `POST /v1/payments/widget` |
79
+ | `create_h2h_payment(amount, currency, external_id, target_currency, ...)` | `POST /v1/payments/h2h` |
80
+ | `get_payment(payment_id)` | `GET /v1/payments/{id}` |
81
+ | `list_payments(limit, offset)` | `GET /v1/payments` |
82
+ | `list_currencies()` | `GET /v1/currencies` |
83
+ | `get_merchant()` | `GET /v1/merchants` |
84
+ | `verify_webhook(secret, headers, raw_body)` | local, no request |
85
+ | `wait_for_payment(payment_id)` | polls `GET /v1/payments/{id}` |
86
+
87
+ Every method exists on both `Cryptunnel` (await it) and `CryptunnelSync` (call it).
88
+
89
+ Payment creation is idempotent on `external_id`: a retry after a network timeout returns the payment
90
+ you already created instead of a second one. Repeating an `external_id` with a different amount or
91
+ currency is rejected with `PAYMENT_ALREADY_EXISTS`.
92
+
93
+ `get_payment` returns two shapes, and the status tells them apart: a `created` or `expired` payment
94
+ carries no `amount`, `currency` or `wallet_address`, because nobody has picked a currency for it
95
+ yet. Read them with `payment.get("amount")` rather than `payment["amount"]` unless you already know
96
+ the status.
97
+
98
+ `wait_for_payment` is for scripts and development - it polls every 5 seconds, backing off to 30, and
99
+ raises `PaymentTimeoutError` after 30 minutes. In production the webhook is the guarantee: a buyer
100
+ who closes the page still produces a callback.
101
+
102
+ ## Errors
103
+
104
+ ```python
105
+ from cryptunnel import AuthenticationError, RateLimitError, ValidationError
106
+
107
+ try:
108
+ cryptunnel.create_h2h_payment(10, "USD", "order-3", "DOGE")
109
+ except ValidationError as error:
110
+ print(error.code) # WALLET_NOT_FOUND - you have no active DOGE wallet
111
+ except AuthenticationError:
112
+ print("check the merchant id and the api key")
113
+ except RateLimitError as error:
114
+ print(error.retry_after) # seconds to wait; None when the API sends no Retry-After header
115
+ ```
116
+
117
+ `CryptunnelError` is the base; `AuthenticationError` (401), `NotFoundError` (404),
118
+ `ValidationError` (400), `RateLimitError` (429) and `ApiError` (5xx and transport failures) derive
119
+ from it. The raw API `code` is always on the exception.
120
+
121
+ ## Links
122
+
123
+ - [Quickstart](https://docs.cryptunnel.io/docs/quickstart) - registration to first payment
124
+ - [Sandbox and faucets](https://docs.cryptunnel.io/docs/sandbox) - test coins without spending any
125
+ - [API reference](https://docs.cryptunnel.io)
126
+ - Support: [GitHub Issues](https://github.com/cryptunnel/cryptunnel-python/issues)
127
+
128
+ MIT licensed.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cryptunnel"
7
+ version = "1.0.0"
8
+ description = "Python SDK for Cryptunnel - accept crypto payments straight to your own wallets"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Cryptunnel" }]
13
+ keywords = ["cryptunnel", "crypto", "payments", "usdt", "tron", "bitcoin", "ethereum"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Office/Business :: Financial",
23
+ ]
24
+ dependencies = ["httpx>=0.27"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://cryptunnel.io"
28
+ Documentation = "https://docs.cryptunnel.io"
29
+ Issues = "https://github.com/cryptunnel/cryptunnel-python/issues"
30
+ Source = "https://github.com/cryptunnel/cryptunnel-python"
31
+
32
+ [dependency-groups]
33
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/cryptunnel"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ testpaths = ["tests"]
@@ -0,0 +1,38 @@
1
+ """Cryptunnel - accept crypto payments straight to your own wallets.
2
+
3
+ ```python
4
+ from cryptunnel import CryptunnelSync
5
+
6
+ cryptunnel = CryptunnelSync("<merchant id>", "<api key>", sandbox=True)
7
+ payment = cryptunnel.create_widget_payment(10, "USD", "order-1")
8
+ print(payment["url"])
9
+ ```
10
+ """
11
+
12
+ from .client import DEFAULT_BASE_URL, TERMINAL_STATUSES, Cryptunnel, CryptunnelSync
13
+ from .errors import (
14
+ ApiError,
15
+ AuthenticationError,
16
+ CryptunnelError,
17
+ NotFoundError,
18
+ PaymentTimeoutError,
19
+ RateLimitError,
20
+ ValidationError,
21
+ )
22
+ from .webhooks import verify_webhook
23
+
24
+ __all__ = [
25
+ "DEFAULT_BASE_URL",
26
+ "TERMINAL_STATUSES",
27
+ "ApiError",
28
+ "AuthenticationError",
29
+ "Cryptunnel",
30
+ "CryptunnelError",
31
+ "CryptunnelSync",
32
+ "NotFoundError",
33
+ "PaymentTimeoutError",
34
+ "RateLimitError",
35
+ "ValidationError",
36
+ "verify_webhook",
37
+ ]
38
+ __version__ = "1.0.0"
@@ -0,0 +1,267 @@
1
+ """The Cryptunnel API clients: ``Cryptunnel`` (async) and ``CryptunnelSync``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Mapping
8
+
9
+ import httpx
10
+
11
+ from .errors import ApiError, PaymentTimeoutError, RateLimitError, error_from_response
12
+
13
+ DEFAULT_BASE_URL = "https://api.cryptunnel.io"
14
+ DEFAULT_TIMEOUT = 30.0
15
+
16
+ #: Statuses a payment never leaves.
17
+ TERMINAL_STATUSES = frozenset({"confirmed", "confirmed_manual", "failed", "expired"})
18
+
19
+
20
+ class _BaseClient:
21
+ """Request building and error mapping, shared by the async and the sync client.
22
+
23
+ The endpoint methods below are plain functions returning ``self._request(...)``: on the async
24
+ client that is a coroutine to await, on the sync client the parsed response.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ merchant_id: str,
30
+ api_key: str,
31
+ sandbox: bool = False,
32
+ base_url: str = DEFAULT_BASE_URL,
33
+ timeout: float = DEFAULT_TIMEOUT,
34
+ ) -> None:
35
+ self.merchant_id = merchant_id
36
+ self.sandbox = sandbox
37
+ self.base_url = base_url.rstrip("/")
38
+ self._options: dict[str, Any] = {
39
+ "base_url": self.base_url,
40
+ "headers": {"x-merchant-id": merchant_id, "x-api-key": api_key},
41
+ "timeout": timeout,
42
+ }
43
+
44
+ def create_widget_payment(
45
+ self,
46
+ amount: float,
47
+ currency: str,
48
+ external_id: str,
49
+ *,
50
+ success_url: str | None = None,
51
+ fail_url: str | None = None,
52
+ callback_url: str | None = None,
53
+ metadata: Mapping[str, Any] | None = None,
54
+ fee_payer: str | None = None,
55
+ ):
56
+ """Create a payment and get the widget url to send the payer to."""
57
+ body = self._payment_body(amount, currency, external_id, callback_url, metadata, fee_payer)
58
+ body.update(_present(success_url=success_url, fail_url=fail_url))
59
+ return self._request("POST", "/v1/payments/widget", json=body)
60
+
61
+ def create_h2h_payment(
62
+ self,
63
+ amount: float,
64
+ currency: str,
65
+ external_id: str,
66
+ target_currency: str,
67
+ *,
68
+ auto_trace: bool = False,
69
+ callback_url: str | None = None,
70
+ metadata: Mapping[str, Any] | None = None,
71
+ fee_payer: str | None = None,
72
+ ):
73
+ """Create a payment and get the wallet address and crypto amount to show yourself.
74
+
75
+ ``target_currency`` must be one of the codes ``list_currencies`` returns.
76
+ """
77
+ body = self._payment_body(amount, currency, external_id, callback_url, metadata, fee_payer)
78
+ body["target_currency"] = target_currency
79
+ body["auto_trace"] = auto_trace
80
+ return self._request("POST", "/v1/payments/h2h", json=body)
81
+
82
+ def get_payment(self, payment_id: str):
83
+ """Read one payment by its Cryptunnel id."""
84
+ return self._request("GET", f"/v1/payments/{payment_id}")
85
+
86
+ def list_payments(self, limit: int = 20, offset: int = 0):
87
+ """List your payments, newest first."""
88
+ return self._request("GET", "/v1/payments", params={"limit": limit, "offset": offset})
89
+
90
+ def list_currencies(self):
91
+ """List the currencies you can receive - exactly the values ``target_currency`` accepts."""
92
+ return self._request("GET", "/v1/currencies", params={"is_test": _flag(self.sandbox)})
93
+
94
+ def get_merchant(self):
95
+ """Read your merchant - the call that tells you the credentials work."""
96
+ return self._request("GET", "/v1/merchants")
97
+
98
+ def _payment_body(
99
+ self,
100
+ amount: float,
101
+ currency: str,
102
+ external_id: str,
103
+ callback_url: str | None,
104
+ metadata: Mapping[str, Any] | None,
105
+ fee_payer: str | None,
106
+ ) -> dict[str, Any]:
107
+ body: dict[str, Any] = {
108
+ "amount": amount,
109
+ "currency": currency,
110
+ "external_id": external_id,
111
+ "is_test": self.sandbox,
112
+ }
113
+ body.update(_present(callback_url=callback_url, metadata=metadata, fee_payer=fee_payer))
114
+ return body
115
+
116
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
117
+ raise NotImplementedError
118
+
119
+ @staticmethod
120
+ def _payload(response: httpx.Response) -> Any:
121
+ try:
122
+ payload = response.json()
123
+ except ValueError:
124
+ payload = None
125
+ if response.is_success:
126
+ return payload
127
+ raise error_from_response(response.status_code, payload, _retry_after(response.headers))
128
+
129
+
130
+ class Cryptunnel(_BaseClient):
131
+ """Async client - the one to use inside a bot or a web framework.
132
+
133
+ ```python
134
+ async with Cryptunnel(merchant_id, api_key, sandbox=True) as cryptunnel:
135
+ payment = await cryptunnel.create_widget_payment(10, "USD", "order-1")
136
+ ```
137
+ """
138
+
139
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
140
+ super().__init__(*args, **kwargs)
141
+ self._http = httpx.AsyncClient(**self._options)
142
+
143
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
144
+ try:
145
+ response = await self._http.request(method, path, **kwargs)
146
+ except httpx.HTTPError as error:
147
+ raise ApiError(f"Request to {path} failed: {error}") from error
148
+ return self._payload(response)
149
+
150
+ async def wait_for_payment(
151
+ self,
152
+ payment_id: str,
153
+ *,
154
+ timeout: float = 1800,
155
+ first_delay: float = 5,
156
+ max_delay: float = 30,
157
+ ) -> Any:
158
+ """Poll until the payment reaches a terminal status.
159
+
160
+ For scripts and development. In production the webhook is the guarantee: a buyer who closes
161
+ the page still gets a callback, a polling process that dies does not.
162
+ """
163
+ waiter = _Waiter(timeout, first_delay, max_delay)
164
+ delay = waiter.next_delay()
165
+ while True:
166
+ await asyncio.sleep(delay)
167
+ if waiter.expired():
168
+ raise PaymentTimeoutError(f"Payment {payment_id} did not settle within {timeout} seconds")
169
+ try:
170
+ payment = await self.get_payment(payment_id)
171
+ except RateLimitError as error:
172
+ delay = waiter.next_delay(error.retry_after)
173
+ continue
174
+ if payment.get("status") in TERMINAL_STATUSES:
175
+ return payment
176
+ delay = waiter.next_delay()
177
+
178
+ async def close(self) -> None:
179
+ await self._http.aclose()
180
+
181
+ async def __aenter__(self) -> "Cryptunnel":
182
+ return self
183
+
184
+ async def __aexit__(self, *exc_info: Any) -> None:
185
+ await self.close()
186
+
187
+
188
+ class CryptunnelSync(_BaseClient):
189
+ """Blocking mirror of :class:`Cryptunnel` for scripts and one-off calls."""
190
+
191
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
192
+ super().__init__(*args, **kwargs)
193
+ self._http = httpx.Client(**self._options)
194
+
195
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
196
+ try:
197
+ response = self._http.request(method, path, **kwargs)
198
+ except httpx.HTTPError as error:
199
+ raise ApiError(f"Request to {path} failed: {error}") from error
200
+ return self._payload(response)
201
+
202
+ def wait_for_payment(
203
+ self,
204
+ payment_id: str,
205
+ *,
206
+ timeout: float = 1800,
207
+ first_delay: float = 5,
208
+ max_delay: float = 30,
209
+ ) -> Any:
210
+ """Poll until the payment reaches a terminal status - see :meth:`Cryptunnel.wait_for_payment`."""
211
+ waiter = _Waiter(timeout, first_delay, max_delay)
212
+ delay = waiter.next_delay()
213
+ while True:
214
+ time.sleep(delay)
215
+ if waiter.expired():
216
+ raise PaymentTimeoutError(f"Payment {payment_id} did not settle within {timeout} seconds")
217
+ try:
218
+ payment = self.get_payment(payment_id)
219
+ except RateLimitError as error:
220
+ delay = waiter.next_delay(error.retry_after)
221
+ continue
222
+ if payment.get("status") in TERMINAL_STATUSES:
223
+ return payment
224
+ delay = waiter.next_delay()
225
+
226
+ def close(self) -> None:
227
+ self._http.close()
228
+
229
+ def __enter__(self) -> "CryptunnelSync":
230
+ return self
231
+
232
+ def __exit__(self, *exc_info: Any) -> None:
233
+ self.close()
234
+
235
+
236
+ class _Waiter:
237
+ """Polling pace: exponential backoff, capped, never sleeping past the deadline."""
238
+
239
+ def __init__(self, timeout: float, first_delay: float, max_delay: float) -> None:
240
+ self.deadline = time.monotonic() + timeout
241
+ self.delay = first_delay
242
+ self.max_delay = max_delay
243
+
244
+ def expired(self) -> bool:
245
+ return time.monotonic() >= self.deadline
246
+
247
+ def next_delay(self, retry_after: float | None = None) -> float:
248
+ # The API sends Retry-After on a 429, but the backoff has to stand on its own if it ever stops
249
+ delay = self.delay if retry_after is None else retry_after
250
+ self.delay = min(self.delay * 2, self.max_delay)
251
+ return max(0.0, min(delay, self.deadline - time.monotonic()))
252
+
253
+
254
+ def _retry_after(headers: Mapping[str, Any]) -> float | None:
255
+ value = headers.get("retry-after")
256
+ try:
257
+ return float(value) # type: ignore[arg-type]
258
+ except (TypeError, ValueError):
259
+ return None
260
+
261
+
262
+ def _flag(value: bool) -> str:
263
+ return "true" if value else "false"
264
+
265
+
266
+ def _present(**values: Any) -> dict[str, Any]:
267
+ return {key: value for key, value in values.items() if value is not None}
@@ -0,0 +1,74 @@
1
+ """Exceptions raised by the client - branch on the type, read the API code from ``code``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class CryptunnelError(Exception):
9
+ """Base for every error this package raises."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ code: str | None = None,
16
+ status: int | None = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ # The raw API code, e.g. PAYMENT_ALREADY_EXISTS, CURRENCY_NOT_FOUND, WALLET_NOT_FOUND
21
+ self.code = code
22
+ self.status = status
23
+
24
+
25
+ class AuthenticationError(CryptunnelError):
26
+ """401: wrong merchant id, wrong or rotated key, or a suspended merchant."""
27
+
28
+
29
+ class NotFoundError(CryptunnelError):
30
+ """404: the payment or currency does not exist for this merchant."""
31
+
32
+
33
+ class ValidationError(CryptunnelError):
34
+ """400: the request was rejected, see ``code`` for which rule."""
35
+
36
+
37
+ class RateLimitError(CryptunnelError):
38
+ """429: too many requests. ``retry_after`` is None when the API sends no header."""
39
+
40
+ def __init__(
41
+ self,
42
+ message: str,
43
+ *,
44
+ code: str | None = None,
45
+ status: int | None = None,
46
+ retry_after: float | None = None,
47
+ ) -> None:
48
+ super().__init__(message, code=code, status=status)
49
+ self.retry_after = retry_after
50
+
51
+
52
+ class ApiError(CryptunnelError):
53
+ """A server-side failure or a transport error."""
54
+
55
+
56
+ class PaymentTimeoutError(CryptunnelError):
57
+ """``wait_for_payment`` gave up before the payment reached a terminal status."""
58
+
59
+
60
+ def error_from_response(status: int, payload: Any, retry_after: float | None = None) -> CryptunnelError:
61
+ """Map an API error response onto the exception hierarchy."""
62
+ body = payload if isinstance(payload, dict) else {}
63
+ code = body.get("code")
64
+ message = body.get("message") or f"Cryptunnel API returned {status}"
65
+
66
+ if status == 401:
67
+ return AuthenticationError(message, code=code, status=status)
68
+ if status == 404:
69
+ return NotFoundError(message, code=code, status=status)
70
+ if status == 429:
71
+ return RateLimitError(message, code=code, status=status, retry_after=retry_after)
72
+ if status == 400:
73
+ return ValidationError(message, code=code, status=status)
74
+ return ApiError(message, code=code, status=status)
@@ -0,0 +1,50 @@
1
+ """Webhook signature verification - the one part of an integration that must not be hand-rolled."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import time
8
+ from typing import Any, Mapping
9
+
10
+ DEFAULT_TOLERANCE = 300
11
+
12
+
13
+ def verify_webhook(
14
+ secret: str,
15
+ headers: Mapping[str, Any],
16
+ raw_body: bytes | str,
17
+ tolerance: int = DEFAULT_TOLERANCE,
18
+ ) -> bool:
19
+ """Verify a callback signed by Cryptunnel.
20
+
21
+ ``raw_body`` must be the bytes as received: parsing and re-serialising the JSON changes the
22
+ signature. Returns False for anything that does not verify - it never raises.
23
+ """
24
+ timestamp = _header(headers, "x-webhook-timestamp")
25
+ signature = _header(headers, "x-webhook-signature")
26
+ if not timestamp or not signature:
27
+ return False
28
+
29
+ try:
30
+ sent_at = int(timestamp)
31
+ except (TypeError, ValueError):
32
+ return False
33
+
34
+ if abs(time.time() - sent_at) > tolerance:
35
+ return False
36
+
37
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
38
+ expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
39
+ # compare_digest is constant time and safe for signatures of the wrong length
40
+ return hmac.compare_digest(expected, signature)
41
+
42
+
43
+ def _header(headers: Mapping[str, Any], name: str) -> str | None:
44
+ value = headers.get(name)
45
+ if value is None:
46
+ # Plain dicts keep the casing the framework handed over
47
+ value = next((v for k, v in headers.items() if k.lower() == name), None)
48
+ if isinstance(value, bytes):
49
+ return value.decode()
50
+ return value if value is None else str(value)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from cryptunnel import (
7
+ ApiError,
8
+ AuthenticationError,
9
+ Cryptunnel,
10
+ CryptunnelSync,
11
+ NotFoundError,
12
+ RateLimitError,
13
+ ValidationError,
14
+ )
15
+
16
+
17
+ def client_returning(status: int, payload: dict, headers: dict | None = None):
18
+ seen: list[httpx.Request] = []
19
+
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ seen.append(request)
22
+ return httpx.Response(status, json=payload, headers=headers)
23
+
24
+ cryptunnel = CryptunnelSync("merchant-id", "ct_live_key", sandbox=True)
25
+ cryptunnel._http = httpx.Client(transport=httpx.MockTransport(handler), **cryptunnel._options)
26
+ return cryptunnel, seen
27
+
28
+
29
+ def test_sandbox_marks_creates_as_test_payments():
30
+ cryptunnel, seen = client_returning(200, {"id": "pay-1", "url": "https://pay.cryptunnel.io/pay-1"})
31
+
32
+ payment = cryptunnel.create_widget_payment(10, "USD", "order-1", success_url="https://shop/ok")
33
+
34
+ assert payment["url"] == "https://pay.cryptunnel.io/pay-1"
35
+ body = seen[0].read().decode()
36
+ assert '"is_test": true' in body.replace('":', '": ')
37
+ assert seen[0].headers["x-merchant-id"] == "merchant-id"
38
+ assert "fail_url" not in body
39
+
40
+
41
+ def test_sandbox_asks_for_the_testnet_currency_family():
42
+ cryptunnel, seen = client_returning(200, {})
43
+
44
+ cryptunnel.list_currencies()
45
+
46
+ assert seen[0].url.params["is_test"] == "true"
47
+
48
+
49
+ def test_h2h_sends_the_target_currency():
50
+ cryptunnel, seen = client_returning(200, {"id": "pay-1"})
51
+
52
+ cryptunnel.create_h2h_payment(10, "USD", "order-1", "USDT")
53
+
54
+ assert '"target_currency"' in seen[0].read().decode()
55
+ assert seen[0].url.path == "/v1/payments/h2h"
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ ("status", "expected"),
60
+ [
61
+ (400, ValidationError),
62
+ (401, AuthenticationError),
63
+ (404, NotFoundError),
64
+ (500, ApiError),
65
+ ],
66
+ )
67
+ def test_error_status_maps_to_its_exception(status, expected):
68
+ cryptunnel, _ = client_returning(status, {"code": "WALLET_NOT_FOUND", "message": "Wallet not found"})
69
+
70
+ with pytest.raises(expected) as raised:
71
+ cryptunnel.get_merchant()
72
+
73
+ assert raised.value.code == "WALLET_NOT_FOUND"
74
+ assert raised.value.status == status
75
+
76
+
77
+ def test_rate_limit_carries_retry_after_when_the_header_is_there():
78
+ cryptunnel, _ = client_returning(429, {"code": "TOO_MANY"}, {"retry-after": "12"})
79
+
80
+ with pytest.raises(RateLimitError) as raised:
81
+ cryptunnel.get_merchant()
82
+
83
+ assert raised.value.retry_after == 12
84
+
85
+
86
+ def test_rate_limit_without_a_header_leaves_retry_after_unset():
87
+ cryptunnel, _ = client_returning(429, {"code": "TOO_MANY"})
88
+
89
+ with pytest.raises(RateLimitError) as raised:
90
+ cryptunnel.get_merchant()
91
+
92
+ assert raised.value.retry_after is None
93
+
94
+
95
+ def test_transport_failures_surface_as_api_errors():
96
+ def handler(request: httpx.Request) -> httpx.Response:
97
+ raise httpx.ConnectError("connection refused")
98
+
99
+ cryptunnel = CryptunnelSync("merchant-id", "ct_live_key")
100
+ cryptunnel._http = httpx.Client(transport=httpx.MockTransport(handler), **cryptunnel._options)
101
+
102
+ with pytest.raises(ApiError):
103
+ cryptunnel.get_merchant()
104
+
105
+
106
+ async def test_the_async_client_maps_errors_the_same_way():
107
+ def handler(request: httpx.Request) -> httpx.Response:
108
+ return httpx.Response(401, json={"code": "INVALID_CREDENTIALS", "message": "Invalid credentials"})
109
+
110
+ cryptunnel = Cryptunnel("merchant-id", "ct_live_key")
111
+ cryptunnel._http = httpx.AsyncClient(transport=httpx.MockTransport(handler), **cryptunnel._options)
112
+
113
+ with pytest.raises(AuthenticationError) as raised:
114
+ await cryptunnel.get_payment("pay-1")
115
+
116
+ assert raised.value.code == "INVALID_CREDENTIALS"
117
+ await cryptunnel.close()
118
+
119
+
120
+ def test_base_url_is_honoured():
121
+ local = CryptunnelSync("merchant-id", "ct_live_key", base_url="http://localhost:3000/")
122
+ assert local._options["base_url"] == "http://localhost:3000"
@@ -0,0 +1,52 @@
1
+ import hashlib
2
+ import hmac
3
+ import time
4
+
5
+ from cryptunnel import verify_webhook
6
+
7
+ SECRET = "whsec_test"
8
+ BODY = b'{"id":"n9cdFaTccYbXecVekHKW8Q","status":"confirmed"}'
9
+
10
+
11
+ def sign(body: bytes, timestamp: int, secret: str = SECRET) -> dict[str, str]:
12
+ signature = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
13
+ return {"x-webhook-timestamp": str(timestamp), "x-webhook-signature": signature}
14
+
15
+
16
+ def test_accepts_a_genuine_callback():
17
+ assert verify_webhook(SECRET, sign(BODY, int(time.time())), BODY) is True
18
+
19
+
20
+ def test_accepts_headers_in_any_casing_and_a_string_body():
21
+ headers = {key.title(): value for key, value in sign(BODY, int(time.time())).items()}
22
+ assert verify_webhook(SECRET, headers, BODY.decode()) is True
23
+
24
+
25
+ def test_rejects_a_tampered_body():
26
+ assert verify_webhook(SECRET, sign(BODY, int(time.time())), BODY + b" ") is False
27
+
28
+
29
+ def test_rejects_a_stale_timestamp():
30
+ assert verify_webhook(SECRET, sign(BODY, int(time.time()) - 301), BODY) is False
31
+
32
+
33
+ def test_accepts_a_stale_timestamp_within_a_wider_tolerance():
34
+ headers = sign(BODY, int(time.time()) - 301)
35
+ assert verify_webhook(SECRET, headers, BODY, tolerance=600) is True
36
+
37
+
38
+ def test_rejects_a_signature_of_the_wrong_length():
39
+ headers = sign(BODY, int(time.time()))
40
+ headers["x-webhook-signature"] = headers["x-webhook-signature"][:10]
41
+ assert verify_webhook(SECRET, headers, BODY) is False
42
+
43
+
44
+ def test_rejects_another_secret():
45
+ assert verify_webhook(SECRET, sign(BODY, int(time.time()), "whsec_other"), BODY) is False
46
+
47
+
48
+ def test_rejects_missing_or_unparsable_headers():
49
+ assert verify_webhook(SECRET, {}, BODY) is False
50
+ headers = sign(BODY, int(time.time()))
51
+ headers["x-webhook-timestamp"] = "not-a-number"
52
+ assert verify_webhook(SECRET, headers, BODY) is False