پرداخت و رمزارز

پرداخت کریپتو با NOWPayments در Django: از invoice تا IPN و پرداخت‌هایی که خبرشان نرسید

NOWPayments را در قالب Django خودم این‌طور پیاده کرده‌ام: ساختن invoice، بررسی HMAC در IPN و باگی که پرداخت‌های بیت‌کوین را جعلی نشان می‌داد، معنای partially_paid، و پیدا کردن پرداخت‌هایی که IPN آن‌ها هیچ‌وقت نرسید.

NOWPayments, Bitcoin, Ethereum and Django

بیش‌تر پروژه‌های Django من از قالب شخصی‌ام، docker-core، ساخته می‌شوند. ماژول اختیاری کریپتوی این قالب (USE_CRYPTO_SERVICE) یک کلاینت کوچک برای NOWPayments دارد. این یادداشت شرح ساختن پرداخت، اعتماد به callback درگاه، خواندن وضعیت و پیدا کردن پرداخت‌هایی است که خبرشان نرسید؛ بیش‌ترش را از باگ‌های محیط واقعی یاد گرفتم.

NOWPayments چیست و چه وقت به کار می‌آید

NOWPayments یک درگاه پرداخت کریپتوست. یک invoice با قیمت دلاری می‌سازید، مشتری در صفحه‌ی پرداخت درگاه با رمزارزی که خودش انتخاب کرده پول می‌دهد و درگاه با هر تغییر وضعیت سرور شما را خبر می‌کند؛ اسم این خبر IPN است. همین قالب می‌تواند پرداخت را از کیف‌پول وصل‌شده‌ی مشتری هم بگیرد و زنجیره را خودش بخواند؛ NOWPayments وقتی به کار می‌آید که مشتری باید از میان سکه‌های زیادی انتخاب کند و نمی‌خواهید تک‌تک زنجیره‌ها را خودتان بخوانید. این کلاینت فقط دریافت می‌کند و payout عمداً در آن نیست.

ساختن پرداخت

هر پرداخت با POST /v1/invoice و هدر x-api-key شروع می‌شود. همه‌ی متدها سه مقدار ok و data و error برمی‌گردانند و ok برابر False فقط یعنی درخواست به نتیجه نرسید، نه این‌که پرداخت رد شد.

payload = {
    "price_amount": float(amount),
    "price_currency": price_currency or NOWPAYMENTS_PRICE_CURRENCY,  # "usd"
    "is_fixed_rate": NOWPAYMENTS_IS_FIXED_RATE,
    "is_fee_paid_by_user": NOWPAYMENTS_IS_FEE_PAID_BY_USER,
}
# NOWPayments refuses an explicit null, so a value never set is left out.
optional = {
    "order_id": str(order_id) if order_id is not None else None,
    "order_description": order_description,
    "ipn_callback_url": ipn_callback_url or NOWPAYMENTS_IPN_CALLBACK_URL,
    "success_url": success_url or NOWPAYMENTS_SUCCESS_URL,
    "cancel_url": cancel_url or NOWPAYMENTS_CANCEL_URL,
    "pay_currency": pay_currency,
    "partially_paid_url": partially_paid_url or NOWPAYMENTS_PARTIALLY_PAID_URL,
}
payload.update({key: value for key, value in optional.items() if value})

ok, data, error = self._request("POST", "/v1/invoice", payload=payload)
if not ok:
    return False, None, error
data["invoice_url"] = normalize_checkout_url(data["invoice_url"])
return True, data, None

دو جزء این کد از محیط واقعی آمده‌اند. NOWPayments مقدار null را نادیده نمی‌گیرد و رد می‌کند، پس فیلد بی‌مقدار اصلاً فرستاده نمی‌شود. درگاه هم آدرس checkout را با یک اسلش آخر برمی‌گرداند و آن را از یک گام روی http ساده redirect می‌کند. checkout یک form POST است، پس مرورگر آن گام را با form-action در CSP می‌سنجد و می‌بندد و مشتری روی صفحه‌ای بی‌پاسخ می‌ماند. normalize_checkout_url اسلش را حذف می‌کند. فراخواننده id فاکتور را نگه می‌دارد تا پرداخت را به سفارش وصل کند.

IPN و این‌که چرا امضایش بررسی می‌شود

آدرس IPN عمومی است و هر کسی می‌تواند بدنه‌ای بفرستد که می‌گوید سفارش شما finished شده. تنها چیزی که NOWPayments را از او جدا می‌کند هدر x-nowpayments-sig است: یک HMAC-SHA512 با IPN secret داشبورد، روی بدنه‌ای با کلیدهای مرتب‌شده و بدون فاصله. بدون secret هیچ پیامی پذیرفته نمی‌شود و مقایسه در زمان ثابت انجام می‌شود. view دریافت IPN، request.body خام و هدر را به این متد می‌دهد و اگر False برگردد جواب 403 می‌دهد.

@staticmethod
def verify_ipn_signature(raw_body, signature) -> bool:
    if not signature or not NOWPAYMENTS_IPN_SECRET_KEY:
        return False
    messages = _signed_messages(raw_body)
    if not messages:  # not JSON
        return False
    key = str(NOWPAYMENTS_IPN_SECRET_KEY).encode()
    return any(
        hmac.compare_digest(
            hmac.new(key, message.encode(), hashlib.sha512).hexdigest(),
            str(signature),
        )
        for message in messages
    )

IPN بیت‌کوینی که جعلی شناخته می‌شد

نسخه‌ی اول امضا را روی خروجی json.dumps با کلیدهای مرتب حساب می‌کرد؛ همان مثال پایتونی مستندات NOWPayments. تا وقتی پای بیت‌کوین وسط نیامده بود کار می‌کرد. NOWPayments امضا را روی خروجی JSON.stringify جاوااسکریپت می‌سازد و پایتون هر عدد کوچک‌تر از 0.0001 را جور دیگری می‌نویسد: 5.123e-05 به‌جای 0.00005123. مبلغ‌ها و کارمزدهای پرداخت بیت‌کوینی دقیقاً همین عددها هستند. پس هر IPN بیت‌کوینی با 403 جعلی شناخته می‌شد و سفارشی که مشتری‌اش تب را بسته بود هیچ‌وقت تسویه نمی‌شد.

حالا بررسی، هر رشته‌ای را که NOWPayments ممکن است امضا کرده باشد به ترتیب احتمال امتحان می‌کند: عددها همان‌طور که در بدنه آمده‌اند، همان‌طور که جاوااسکریپت می‌نویسد، و شکل json.dumps. این کار بررسی را سست نمی‌کند، چون هر رشته باز هم با همان secret و روی همان داده امضا می‌شود.

def _signed_messages(raw_body) -> list:
    as_sent = json.loads(raw_body, parse_float=_JsonNumber, parse_int=_JsonNumber)
    parsed = json.loads(raw_body)
    messages = (
        _sorted_json(as_sent, str),        # each number as the body wrote it
        _sorted_json(parsed, _js_number),  # as JavaScript writes it
        json.dumps(parsed, sort_keys=True, separators=(",", ":")),  # the Python example
    )
    return list(dict.fromkeys(messages))

هر وضعیت یعنی چه

check_payment نُه وضعیت NOWPayments را به یک حکم تبدیل می‌کند:

payment_statusحکمفراخواننده
finishedSTATE_PAIDسفارش را تسویه می‌کند
partially_paidSTATE_PARTIALLY_PAIDارزش بخش رسیده را تعیین می‌کند
waiting، confirming، confirmed، sendingSTATE_PENDINGصبر می‌کند
failed، refunded، expiredSTATE_FAILEDسفارش را می‌بندد
وضعیت ناشناختهSTATE_PENDINGصبر می‌کند؛ تغییر API چیزی را از بین نمی‌برد
درگاه در دسترس نیستSTATE_SERVICE_ERRORسفارش را نگه می‌دارد و بعداً می‌پرسد

پاسخ 404 شکست حساب می‌شود: درگاه جواب داده و این پرداخت را نمی‌شناسد.

partially_paid به معنای «در انتظار» نیست. اول همین‌طور حسابش کرده بودم؛ باگ دوم همین بود. سکه‌ها رسیده‌اند، اما کم‌تر از مبلغ invoice، و هر چه مشتری بعداً اضافه بفرستد پرداخت جداگانه‌ای با parent_payment_id ثبت می‌شود. این یکی هیچ‌وقت finished نمی‌شود و فراخواننده‌ای که منتظرش بماند، با پولی که رسیده، تا ابد منتظر می‌ماند. این‌که چه مقدار برای مشتری ثبت شود تصمیم پروژه است؛ سهم رسیده actually_paid / pay_amount است.

پرداخت‌هایی که IPN آن‌ها نرسید

IPN ممکن است گم شود: فایروال، سروری که از خارج در دسترس نیست، یا ری‌استارتی طولانی‌تر از مهلت تلاش‌های دوباره‌ی درگاه. اگر مشتری هم برنگردد، فقط فهرست پرداخت‌های حساب، GET /v1/payment/، از این پرداخت خبر دارد. این فهرست یک JWT از POST /v1/auth می‌خواهد و آن هم ایمیل و رمز خود داشبورد را؛ همان ورودی که payout هم با آن ساخته می‌شود. پس این بخش اختیاری است و حصار دارد:

  • بدون NOWPAYMENTS_EMAIL و NOWPAYMENTS_PASSWORD هیچ درخواستی فرستاده نمی‌شود.
  • اطلاعات ورود یا روی https می‌رود یا اصلاً نمی‌رود.
  • توکن فقط به فراخوانی‌ای برمی‌گردد که آن را خواسته و هیچ‌جا نگه داشته نمی‌شود: نه روی کلاینت، نه در cache (چون Redis رمز ندارد)، نه در لاگ. پنج دقیقه عمر دارد.
  • هیچ پیام خطایی اطلاعات ورود یا توکن را نشان نمی‌دهد.
def get_account_payments(self, date_from=None, date_to=None, invoice_id=None):
    ok, token, error = self.authenticate()
    if not ok:
        return False, [], error
    payments, page, renewed = [], 0, False
    while page < ACCOUNT_PAYMENTS_MAX_PAGES:
        ok, data, error, http_status = self.list_payments(
            token, date_from, date_to, invoice_id, page
        )
        if not ok and http_status in (401, 403) and not renewed:
            renewed = True
            ok, token, error = self.authenticate()
            if ok:
                continue
        if not ok:
            return False, [], error  # all the pages, or none
        rows, pages = _page_rows(data)
        payments.extend(rows)
        page += 1
        if not rows or page >= pages:
            return True, payments, None
    return False, [], f"Error: more than {ACCOUNT_PAYMENTS_MAX_PAGES} pages of payments."

get_account_payments یا همه‌ی صفحه‌های بازه را برمی‌گرداند یا هیچ‌چیز؛ فراخواننده‌ای که یک صفحه را به‌جای کل فهرست بگیرد، بقیه را پرداخت‌نشده حساب می‌کند. توکن منقضی‌شده فقط یک بار تمدید می‌شود.

نیمه‌ی دیگر کار با سرور است: فایل src/core/.env با chmod 600 و به مالکیت کاربر deploy نگه داشته می‌شود، و دسترسی به docker همان دسترسی root است، چون docker inspect environment همه‌ی کانتینرها را نشان می‌دهد. در داشبورد هم احراز هویت دومرحله‌ای باید روشن باشد، payoutها با آن تأیید شوند و آدرس‌های payout در whitelist باشند؛ آن‌وقت رمز لورفته هم پولی جابه‌جا نمی‌کند.

کلیدها در فایل env، نه در کد

در این قالب هر تنظیم به‌طور پیش‌فرض در دیتابیس است. کلیدی به فایل env می‌رود که چیزی را احراز هویت کند و نشود رمزنگاری‌شده نگهش داشت، و همه‌ی کلیدهای NOWPayments از همین جنس‌اند:

NOWPAYMENTS_SANDBOX=False
NOWPAYMENTS_API_KEY=<your-api-key>
NOWPAYMENTS_IPN_SECRET_KEY=<your-ipn-secret>

# optional: only for the account's list of payments
NOWPAYMENTS_EMAIL='<dashboard-email>'
NOWPAYMENTS_PASSWORD='<dashboard-password>'

# optional: where api.nowpayments.io cannot be reached directly
NOWPAYMENTS_USE_PROXIES=False
NOWPAYMENTS_PROXY_URL=socks5://host.docker.internal:1085

تک‌کوتیشن مهم است: وگرنه compose علامت $ را جایگزین می‌کند و رمز تغییرکرده به کانتینر می‌رسد. بعد از عوض کردن رمز باید docker compose up -d اجرا شود؛ restart همان environment قبلی را نگه می‌دارد.

تست

تست‌ها هیچ‌وقت به NOWPayments وصل نمی‌شوند؛ requests.request با یک درگاه جعلی کوچک patch می‌شود که درخواست‌ها را ثبت می‌کند.

  • fixture مربوط به IPN بدنه‌ی یک پرداخت بیت‌کوینی است، کنار رشته‌ای که کد مستند خود NOWPayments زیر Node از آن ساخته است. امضای جعلی یا جاافتاده رد می‌شود.
  • هر وضعیت مستند به حکم خودش می‌رسد و وضعیت ناشناخته به pending.
  • برای حساب: بدون اطلاعات ورود یا با base URL روی http هیچ درخواستی فرستاده نمی‌شود؛ ورود ردشده ایمیل و رمز را در خطا و stdout نشان نمی‌دهد؛ و یک 401 وسط فهرست ترتیب auth، صفحه، auth، صفحه را می‌سازد.
def test_python_writes_small_amounts_another_way():
    """The cause: the check signed `json.dumps`'s string."""
    as_python = json.dumps(json.loads(JS_BODY), sort_keys=True, separators=(",", ":"))
    assert as_python != JS_SIGNED
    assert "5.123e-05" in as_python


def test_an_ipn_signed_over_small_bitcoin_amounts_is_accepted():
    assert _verify(JS_BODY.encode(), _sign(JS_SIGNED))
docker compose run --rm -e USE_CRYPTO_SERVICE=True backend uv run pytest _0_utils/_modules/crypto --nomigrations

روی سرور، فرمان nowpayments_doctor همان مسیر را از داخل کانتینر طی می‌کند و در اولین قدم ناموفق می‌ایستد: تنظیمات (فقط چهار حرف اول API key)، سوکت پروکسی و GET /v1/status. می‌تواند یک invoice واقعی هم بسازد، یا وارد حساب شود و پرداخت‌های روز گذشته را بشمارد، بی‌آن‌که اطلاعات ورود یا توکن را چاپ کند.

uv run manage.py nowpayments_doctor
uv run manage.py nowpayments_doctor --invoice 100
uv run manage.py nowpayments_doctor --account

آن‌چه از این کار برایم ماند

امضا را روی همان رشته‌ای بسنجید که فرستنده امضا کرده، نه رشته‌ای که زبان شما می‌نویسد. وضعیت ناشناخته را «در انتظار» بگیرید و پرداخت ناقص را نهایی. و اطلاعات ورود داشبورد را هم‌وزن یک کلید payout ببینید، چون واقعاً همان است.