coinwingClosed beta Запросить доступ

Интеграция · 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 выдаёт адреса, следит за сетями и сообщает о переводах. Деньги ведёт ваш проект: балансы, валюта, правила зачисления.

Четыре правила, на которых держится всё остальное:

  1. Адрес постоянный. Один на клиента, навсегда. Его можно запрашивать сколько угодно раз, ответ всегда тот же.
  2. Один адрес на все сети. Один и тот же 0x… принимает переводы во всех EVM-сетях, за которыми следит Coinwing.
  3. Зачисляем только по deposit.confirmed. detected ещё может исчезнуть при реорганизации блоков.
  4. Суммы — целые числа в строках. "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)

Почему так:

Шаг 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)

Эндпоинт для дашборда — обычный 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)

Что важно:

Ситуация Ответ
Событие обработано или проигнорировано 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

Правила:

  1. Блокировка строки поступления, повторная проверка статуса и OneToOneField на проводку баланса. Вместе они не дают зачислить дважды даже при параллельных доставках.
  2. Проводка баланса и смена статуса — в одной транзакции. Падение между ними не оставит одно без другого.
  3. Письма и прочие побочные эффекты — в 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. Поэтому:

Если в вашем проекте баланс только в рублях, нужна конвертация по курсу. Курс фиксируйте на самом поступлении, а при отсутствии свежего курса отвечайте вебхуку 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}",
    )

Минимальный набор, который ловит реальные ошибки:

  1. deposit.confirmed зачисляет нужную сумму.
  2. Одно и то же событие дважды зачисляет один раз.
  3. Изменённое тело отклоняется с 400, ничего не зачисляется.
  4. Больше 40 вызовов подряд не упираются в 429.
  5. deposit.detected ничего не зачисляет.
  6. usd: null не зачисляется и остаётся в confirmed.
  7. Поступление для команды с рублёвым балансом не зачисляется.
  8. deposit.orphaned после зачисления возвращает баланс.
  9. Выдача адреса закрыта для тех, кому способ оплаты недоступен.

HTTP-клиент Coinwing в тестах подменяется через unittest.mock.patch.

Шаг 11. Запуск в продакшене

Что делать, если что-то пошло не так

Симптом Причина Решение
Клиент заплатил, в 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 Добавить ограничение, разобрать дубли