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.
- cryptunnel-1.0.0/.github/dependabot.yml +16 -0
- cryptunnel-1.0.0/.github/workflows/publish.yml +23 -0
- cryptunnel-1.0.0/.github/workflows/test.yml +20 -0
- cryptunnel-1.0.0/.gitignore +7 -0
- cryptunnel-1.0.0/CHANGELOG.md +15 -0
- cryptunnel-1.0.0/LICENSE +21 -0
- cryptunnel-1.0.0/PKG-INFO +152 -0
- cryptunnel-1.0.0/README.md +128 -0
- cryptunnel-1.0.0/pyproject.toml +40 -0
- cryptunnel-1.0.0/src/cryptunnel/__init__.py +38 -0
- cryptunnel-1.0.0/src/cryptunnel/client.py +267 -0
- cryptunnel-1.0.0/src/cryptunnel/errors.py +74 -0
- cryptunnel-1.0.0/src/cryptunnel/webhooks.py +50 -0
- cryptunnel-1.0.0/tests/test_client.py +122 -0
- cryptunnel-1.0.0/tests/test_webhooks.py +52 -0
|
@@ -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,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.
|
cryptunnel-1.0.0/LICENSE
ADDED
|
@@ -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
|