Интеграция · Python
Интеграция с Coinwing: пример для Django-проекта
Руководство показывает, как подключить приём USDC через Coinwing к проекту на Django + DRF + Celery. Пример взят из Statusmate: там пополняется долларовый баланс команды бренда statusmate.top. Тот же порядок подойдёт любому проекту с предоплаченным балансом.
Справочник по API Coinwing — INTEGRATION.md в репозитории Coinwing. Здесь описано, что нужно сделать на стороне вашего проекта.
Как это работает
ваш проект Coinwing блокчейн
│ POST /addresses │ │
├──────────────────────────▶│ выдаёт адрес из вашего xpub │
│◀──────────────────────────┤ │
│ показываем адрес клиенту │ │
│ │ клиент шлёт USDC ──────▶│
│ deposit.detected │◀── перевод замечен │
│◀──────────────────────────┤ │
│ deposit.confirmed │◀── блок финализирован │
│◀──────────────────────────┤ │
│ зачисляем на баланс │ │
Coinwing выдаёт адреса, следит за сетями и сообщает о переводах. Деньги ведёт ваш проект: балансы, валюта, правила зачисления.
Четыре правила, на которых держится всё остальное:
- Адрес постоянный. Один на клиента, навсегда. Его можно запрашивать сколько угодно раз, ответ всегда тот же.
- Один адрес на все сети. Один и тот же
0x…принимает переводы во всех EVM-сетях, за которыми следит Coinwing. - Зачисляем только по
deposit.confirmed.detectedещё может исчезнуть при реорганизации блоков. - Суммы — целые числа в строках.
"1000000"— это 1 USDC (6 знаков). Никакихfloat.
Шаг 1. Зарегистрировать проект в Coinwing
У каждого проекта свой кошелёк и своя мнемоника, общий xpub не используем.
На отключённой от сети машине в репозитории Coinwing:
make xpub
Мнемонику записываем на бумагу: только ею можно вывести средства. В Coinwing отдаём один xpub.
На сервере Coinwing:
.venv/bin/python manage.py new_merchant statusmate \
--name Statusmate \
--xpub xpub6Ci… \
--webhook-url https://statusmate.ru/api/coinwing/webhook/
Команда один раз покажет API-ключ (cw_…) и секрет вебхука (whsec_…). Позже их можно сменить в панели Coinwing.
Шаг 2. Настройки
# settings.py
COINWING_URL = os.getenv("COINWING_URL") or "https://coinwing.ru"
COINWING_API_KEY = os.getenv("COINWING_API_KEY") or ""
COINWING_WEBHOOK_SECRET = os.getenv("COINWING_WEBHOOK_SECRET") or ""
COINWING_TIMEOUT = int(os.getenv("COINWING_TIMEOUT") or "10")
or вместо значения по умолчанию в getenv — намеренно: пустая переменная COINWING_URL= в старом stack-файле иначе перебьёт значение по умолчанию.
Пустой секрет вебхука должен приводить к ответу 503. Проверка подписи с пустым ключом ничего не проверяет.
Шаг 3. Клиент
Скопируйте clients/coinwing_client.py из репозитория Coinwing в проект без изменений, например в payments/coinwing_client.py. Ему нужен только requests. Чтобы обновить клиент, скопируйте файл заново: локальные правки разойдутся с сервисом, который подписывает вебхуки.
В файле есть всё нужное:
| Что | Зачем |
|---|---|
CoinwingClient.get_or_create_address(external_id, label) |
Выдать или вернуть адрес клиента |
CoinwingClient.get_deposit(deposit_id) |
Проверить поступление вручную |
CoinwingWebhook.verify(secret, body, header) |
Проверить подпись вебхука |
deposit_cents(data) |
Сумма в центах или None, если оценить не удалось |
Шаг 4. Кто владелец адреса
external_id — ваш идентификатор владельца денег. Coinwing вернёт его в каждом вебхуке как account_external_id.
Выбирайте того, чей баланс пополняется:
| Модель баланса | external_id |
|---|---|
| Баланс у пользователя | str(user.pk) |
| Баланс у команды или организации | str(team.pk) |
В Statusmate баланс у команды, поэтому external_id = str(team.pk).
Однажды выбранный external_id менять нельзя. Он связывает уже выданные адреса с владельцами, после смены все прошлые переводы потеряют хозяина.
Шаг 5. Модели
class CryptoAddress(BaseModel):
"""Постоянный адрес команды, закэшированный ответ Coinwing."""
team = models.OneToOneField(Team, related_name="crypto_address", on_delete=models.CASCADE)
address = models.CharField(max_length=64, unique=True)
chains = models.JSONField(default=list, blank=True)
class CryptoDeposit(BaseModel):
"""Одно поступление; зачисляется не больше одного раза."""
class Status(models.TextChoices):
DETECTED = "detected"
CONFIRMED = "confirmed"
CREDITED = "credited"
ORPHANED = "orphaned"
coinwing_deposit_id = models.UUIDField(unique=True) # ключ идемпотентности
team = models.ForeignKey(Team, related_name="crypto_deposits", on_delete=models.PROTECT)
chain = models.CharField(max_length=50)
asset = models.CharField(max_length=10, default="token")
token_symbol = models.CharField(max_length=20, blank=True)
amount = models.DecimalField(max_digits=78, decimal_places=0) # uint256
tx_hash = models.CharField(max_length=80)
log_index = models.IntegerField()
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DETECTED)
usd_cents = models.BigIntegerField(null=True, blank=True) # null — сумма неизвестна
balance_change = models.OneToOneField(
BalanceChange, null=True, blank=True, on_delete=models.PROTECT
)
confirmed_at = models.DateTimeField(null=True, blank=True)
credited_at = models.DateTimeField(null=True, blank=True)
Почему так:
unique=Trueнаcoinwing_deposit_id— главная защита от двойного зачисления. Проверкаif exists()гоняется сама с собой и не спасает.amountхранится какDecimalна 78 цифр, а неBigInteger: в wei у ETH 18 знаков,bigintпереполнится уже на 9,22 ETH.usd_centsдопускаетnull. Пустое значение означает «неизвестно», а не ноль.balance_changeсвязывает поступление с проводкой баланса.OneToOneField— вторая защита от двойного зачисления, а по проводке видно, сколько и когда зачислили.
Шаг 6. Выдача адреса
class Coinwing:
@staticmethod
def client() -> CoinwingClient:
return CoinwingClient(
settings.COINWING_URL, settings.COINWING_API_KEY, timeout=settings.COINWING_TIMEOUT
)
@classmethod
def address_for(cls, team: Team) -> CryptoAddress:
existing = CryptoAddress.objects.filter(team=team).first()
if existing and existing.chains:
return existing
data = cls.client().get_or_create_address(str(team.pk), label=team.slug)
try:
with transaction.atomic():
return CryptoAddress.objects.create(
team=team, address=data["address"].lower(), chains=data.get("chains") or []
)
except IntegrityError:
return CryptoAddress.objects.get(team=team)
- Адрес кэшируем у себя. Тогда экран оплаты открывается, даже когда Coinwing недоступен.
IntegrityErrorпри гонке двух запросов не страшен: Coinwing вернул обоим один и тот же адрес, достаточно перечитать строку.- Сами адреса не выводим никогда. Если два сервиса будут выводить адреса из одного xpub, один адрес достанется двум клиентам.
Эндпоинт для дашборда — обычный DRF action. Перед запросом в Coinwing проверяем права:
@action(detail=False, methods=["POST"])
def address(self, request):
s = CryptoAddressRequestSerializer(data=request.data, context={"request": request})
s.is_valid(raise_exception=True) # член команды + разрешён ли способ оплаты
if not settings.COINWING_API_KEY:
raise CryptoUnavailable() # 503
try:
address = Coinwing.address_for(s.validated_data["team"])
except (CoinwingError, OSError):
raise CryptoUnavailable()
return Response(CryptoAddressSerializer(instance=address).data)
В Statusmate способ доступен только командам с долларовым балансом (бренд statusmate.top), проверка — CryptoAccess.allowed(team) в сериализаторе. Ограничивайте выдачу адреса, а не приём вебхука: если деньги уже пришли, их нужно зачислить.
Шаг 7. Вебхук
@extend_schema(exclude=True)
@csrf_exempt
@api_view(["POST"])
@permission_classes([AllowAny])
@throttle_classes([])
def coinwing_webhook(request):
if not settings.COINWING_WEBHOOK_SECRET:
return JsonResponse({"error": "webhook secret not configured"}, status=503)
try:
payload = CoinwingWebhook.verify(
settings.COINWING_WEBHOOK_SECRET,
request.body, # сырые байты
request.headers.get("Coinwing-Signature", ""),
)
except InvalidSignature as exc:
return JsonResponse({"error": str(exc)}, status=400)
except ValueError:
return JsonResponse({"error": "malformed payload"}, status=400)
try:
with transaction.atomic():
CryptoDeposits.handle(payload.get("event", ""), payload.get("data") or {})
except (KeyError, TypeError, ValueError):
return JsonResponse({"error": "malformed payload"}, status=400) # повтор не поможет
except IntegrityError:
pass # параллельная доставка того же события успела раньше
return HttpResponse(status=200)
Что важно:
- Подпись проверяем по
request.body. Повторно сериализованный JSON не совпадёт с подписью. До проверки тело считается присланным злоумышленником. csrf_exemptобязателен: запрос аутентифицирован подписью, а не сессией.- Уберите лимит запросов DRF. Все доставки идут с одного IP Coinwing, и общий лимит для анонимных запросов (в Statusmate — 40 в минуту) при пачке поступлений начнёт отвечать
429. Coinwing повторит, но зачисление задержится на часы. Запрос и так аутентифицирован подписью, поэтому@throttle_classes([]). - Путь должен отвечать только на основном домене сервиса. В Statusmate
/api/coinwing/добавлен вBLOCKED_PREFIXES, чтобы его не было на доменах клиентов. - Код ответа управляет повторами. Coinwing повторяет всё, кроме
2xx, через 1 мин, 5 мин, 25 мин, 2 ч, 6 ч и 24 ч. Поэтому:
| Ситуация | Ответ |
|---|---|
| Событие обработано или проигнорировано | 200 |
| Неизвестный тип события | 200, иначе он забьёт очередь повторов |
Неизвестный account_external_id |
200 и ошибка в лог: зачислять некому |
| Неверная подпись или битое тело | 400 |
| Временная проблема на нашей стороне, например не настроен секрет | 503 |
Шаг 8. Обработка событий
class CryptoDeposits:
@classmethod
def handle(cls, event: str, data: dict) -> None:
handler = {
"deposit.detected": cls.detected,
"deposit.confirmed": cls.confirmed,
"deposit.orphaned": cls.orphaned,
}.get(event)
if handler:
handler(data)
| Событие | Что делаем |
|---|---|
deposit.detected |
Создаём строку со статусом detected. Клиент видит «ждём подтверждения». Деньги не трогаем |
deposit.confirmed |
Статус confirmed, затем зачисление. Единственное событие, которое двигает деньги |
deposit.orphaned |
До зачисления — просто статус orphaned. После зачисления — обратная проводка и громкая ошибка в лог |
Строку ищем или создаём по coinwing_deposit_id под select_for_update(). Так повторная доставка и редоставка из панели Coinwing попадают в ту же строку.
Шаг 9. Зачисление ровно один раз
@classmethod
def credit(cls, deposit: CryptoDeposit) -> CryptoDeposit:
deposit = CryptoDeposit.objects.select_for_update().get(pk=deposit.pk)
if deposit.status == CryptoDeposit.Status.CREDITED:
return deposit
if deposit.team.currency != "USD":
return deposit # ждёт сотрудника, см. ниже
usd = Decimal(deposit.usd_cents) / 100
deposit.balance_change = cls._change_balance(deposit.team, usd, reason_user)
deposit.status = CryptoDeposit.Status.CREDITED
deposit.credited_at = now()
deposit.save()
transaction.on_commit(lambda: notify_owners(deposit.team))
return deposit
Правила:
- Блокировка строки поступления, повторная проверка статуса и
OneToOneFieldна проводку баланса. Вместе они не дают зачислить дважды даже при параллельных доставках. - Проводка баланса и смена статуса — в одной транзакции. Падение между ними не оставит одно без другого.
- Письма и прочие побочные эффекты — в
transaction.on_commit, иначе при откате клиент получит письмо о пополнении, которого не было.
Сумма в долларах
Берите usd.cents из вебхука, хелпер deposit_cents(data). Значение верно и для USDC, и для нативной монеты.
cents = deposit_cents(data) # int или None
None значит, что все источники цен были недоступны. Такое поступление не зачисляем как ноль: оставляем в confirmed и разбираем вручную. В Statusmate для этого есть действие в админке «Credit confirmed deposits now».
Не используйте base_units_to_cents для чего-либо кроме стейблкоина 1:1. Для ETH она превратит 1 ETH в один доллар.
Валюта баланса
USDC и USDT — это доллары. Центы можно записать только в долларовый баланс. Записанные как есть в рублёвый, они раздуют его примерно в 80 раз, и потом это никак не обнаружить.
В Statusmate валюта баланса задаётся брендом команды: statusmate.top — USD, statusmate.ru — RUB. Поэтому:
- Адрес выдаётся только USD-команде. Остальные не могут получить реквизиты для перевода.
- Перед зачислением ещё раз проверяется валюта баланса. Если перевод всё же пришёл RUB-команде, например на адрес, выданный до смены бренда, поступление остаётся в
confirmedи ошибка уходит в лог. Дальше решает сотрудник. - Сумма зачисляется без конвертации:
usd.cents / 100, типDecimalс двумя знаками. Баланс и цены тарифов тоже хранятся вDecimal(12, 2), иначе дневная цена $0.25 не помещается.
Если в вашем проекте баланс только в рублях, нужна конвертация по курсу. Курс фиксируйте на самом поступлении, а при отсутствии свежего курса отвечайте вебхуку 503: Coinwing повторит доставку позже.
Шаг 10. Тесты
Подпись в тесте собирается так же, как в Coinwing:
def _post(self, payload, secret=SECRET):
body = json.dumps(payload, separators=(",", ":")).encode()
ts = int(time.time())
sig = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return self.client.post(
"/api/coinwing/webhook/", body,
content_type="application/json",
HTTP_COINWING_SIGNATURE=f"t={ts},v1={sig}",
)
Минимальный набор, который ловит реальные ошибки:
deposit.confirmedзачисляет нужную сумму.- Одно и то же событие дважды зачисляет один раз.
- Изменённое тело отклоняется с
400, ничего не зачисляется. - Больше 40 вызовов подряд не упираются в
429. deposit.detectedничего не зачисляет.usd: nullне зачисляется и остаётся вconfirmed.- Поступление для команды с рублёвым балансом не зачисляется.
deposit.orphanedпосле зачисления возвращает баланс.- Выдача адреса закрыта для тех, кому способ оплаты недоступен.
HTTP-клиент Coinwing в тестах подменяется через unittest.mock.patch.
Шаг 11. Запуск в продакшене
- Мнемоника сгенерирована офлайн и хранится на бумаге.
- В Coinwing передан только xpub.
- В окружении заданы
COINWING_API_KEYиCOINWING_WEBHOOK_SECRET. - URL вебхука в Coinwing — HTTPS и доступен с сервера Coinwing.
- Путь вебхука не отвечает на клиентских доменах.
- Миграции применены,
coinwing_deposit_idуникален в базе. - Для бренда с долларовым балансом заведены тарифы в USD.
https://coinwing.ru/api/v1/healthдобавлен в мониторинг.- Проведён реальный перевод на 1 USDC в сети Base и он зачислился. Только такой тест доказывает, что адрес, сеть, контракт и вебхук согласованы.
Что делать, если что-то пошло не так
| Симптом | Причина | Решение |
|---|---|---|
| Клиент заплатил, в Coinwing пусто | Не та сеть или не тот токен | Проверить транзакцию в обозревателе нужной сети |
Поступление висит в detected |
Ждёт финализации | Норма для Base 10–15 минут. Разбираться после 30 минут |
В Coinwing confirmed, у нас не зачислено |
Вебхук падал | Карточка поступления в панели Coinwing покажет все попытки. Починить и нажать Redeliver |
Вебхук отвечает 503 |
Не задан COINWING_WEBHOOK_SECRET |
Проверить настройки |
Поступление в confirmed, баланс не изменился |
Перевод пришёл команде с рублёвым балансом | Разобрать вручную: вернуть средства или сменить бренд команды |
| Ошибка подписи | Тело пересериализовано или сменили секрет | Проверять request.body, сверить секрет с панелью |
Поступление в confirmed с usd_cents = null |
Coinwing не смог оценить сумму | Посмотреть GET /deposits/<id>: значение могло появиться позже. Зачислить вручную |
| Зачислилось дважды | Нет уникального ключа на deposit_id |
Добавить ограничение, разобрать дубли |