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

پرداخت بیت‌کوین و لایت‌کوین با پلن رایگان BlockCypher: زیر سقف درخواست، بی‌توکن در لاگ، بی پرداخت تکراری

صف پرداختی که با Celery beat کار می‌کرد، سهمیه‌ی ساعتی پلن رایگان BlockCypher را تمام کرد و توکن را به لاگ پرداخت برد. چطور درخواست‌ها را کم کردم، بالای سقف تا ساعت بعد صبر کردم، توکن را از خطاها پاک کردم و نگذاشتم پرداختی دو بار ساخته شود.

BlockCypher, Bitcoin and Litecoin

چند پروژه‌ی Django من برداشت کاربرها را به رمزارز پرداخت می‌کنند و بیت‌کوین و لایت‌کوین (و دوج‌کوین) را از راه BlockCypher، روی پلن رایگانش، می‌فرستند. این یادداشت شرح این است که صف پرداخت چطور سهمیه‌ی ساعتی BlockCypher را تمام کرد، توکن API چطور به لاگ پرداخت رسید، و چه کردم که هیچ پرداختی دو بار ساخته نشود.

صحنه

برداشت‌ها در یک صف پرداخت منتظر می‌مانند. Celery beat هر پنج دقیقه صف را می‌خواند و برای هر برداشت یک task جدا راه می‌اندازد؛ موجودی کیف‌پول‌های پرداخت (hot wallet) را هم برای داشبورد ادمین تازه می‌کند.

هر پرداخت از راه BlockCypher سه قدم است: POST txs/new یک اسکلت تراکنش با فهرست tosign برمی‌گرداند، سرور خودم آن‌ها را با کلید WIF امضا می‌کند که هیچ‌وقت از سرور بیرون نمی‌رود، و POST txs/send تراکنش امضاشده را پخش می‌کند. توکن API به‌صورت پارامتر token در query string می‌رود.

پلن رایگان سه درخواست در ثانیه، صد در ساعت و هزار در روز اجازه می‌دهد و ساعت از سرِ ساعت (UTC) شمرده می‌شود. این برای یک صف پرداخت کافی است، اگر هدر نرود.

چه چیزی خراب شد

  • دو خواندن موجودی برای هر پرداخت. پرداخت‌کننده پیش از ارسال موجودی را چک می‌کرد و فرستنده دوباره آن را می‌خواند، فقط برای یک print.
  • ردشده، دوباره در هر tick. پرداختِ ردشده در صف می‌ماند و پنج دقیقه بعد با همان درخواست‌ها دوباره امتحان می‌شد.
  • پرداخت‌های یک tick روی سکه‌های یکسان. چند پرداخت هم‌زمان از یک کیف‌پول ساخته می‌شدند. BlockCypher اولی را می‌فرستاد و بقیه را با خطای already locked by رد می‌کرد، چون ورودی‌شان را تراکنش اول قفل کرده بود. هر رد هم از سهمیه کم می‌شد.
  • Limits reached تا آخر ساعت. بعد از اولین رد برای سقف، BlockCypher هر درخواستی را تا چرخیدن ساعت رد می‌کند و باز هم می‌شمارد. امتحان دوباره‌ی هر پنج دقیقه فقط سهمیه را خالی نگه می‌داشت.
  • توکن در لاگ پرداخت. HTTPError در requests آدرسی را که رویش شکست خورده در پیامش می‌نویسد، پارامتر token را هم. آن پیام مستقیم به لاگ پرداختی می‌رفت که هر ادمینی می‌خواند، و به کنسول.

اصلاح‌ها

توکن هیچ‌وقت در متن نمی‌ماند

هر خطایی که نگه داشته می‌شود (مثلاً دلیل آخرین شکستِ خواندن موجودی که last_balance_error به داشبورد می‌دهد) یا چاپ می‌شود، اول از تابع redact رد می‌شود. این تابع هم مقدار پارامتر token را در هر URL می‌پوشاند و هم خودِ توکن را، هر جای متن که باشد:

_TOKEN_IN_TEXT = re.compile(r"(token=)[^&\s'\"]+")


def redact(text) -> str:
    """`text` with the API token taken out."""
    text = _TOKEN_IN_TEXT.sub(r"\1***", str(text))
    if BLOCK_CYPHER_API_TOKEN:
        text = text.replace(str(BLOCK_CYPHER_API_TOKEN), "***")
    return text

بالای سقف، تا ساعت بعد هیچ درخواستی

یک پاسخ 429، یا Limits reached در بدنه‌ی پاسخ، BlockCypher را تا یک دقیقه بعد از سرِ ساعت بعدی متوقف می‌کند. زمان توقف در cache جنگو (این‌جا Redis) نوشته می‌شود تا همه‌ی workerهای Celery آن را ببینند:

def _is_over_the_limit(status_code, body) -> bool:
    if status_code == 429:
        return True
    text = json.dumps(body) if isinstance(body, (dict, list)) else str(body or "")
    return "Limits reached" in text


def pause_blockcypher() -> datetime:
    """Ask BlockCypher nothing until a minute past the next hour."""
    current = datetime.now(timezone.utc)
    until = current.replace(minute=0, second=0, microsecond=0) + timedelta(
        hours=1, minutes=1
    )
    seconds = int((until - current).total_seconds()) + 60
    cache.set(PAUSE_CACHE_KEY, until.isoformat(), timeout=seconds)
    return until

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

درخواست کمتر

  • موجودی یک کیف‌پول BALANCE_FRESH_SECONDS، یعنی نه دقیقه، در cache می‌ماند؛ کمی کمتر از دو tick. با beat پنج‌دقیقه‌ای، موجودی یک tick در میان خوانده می‌شود: شش درخواست در ساعت برای هر کیف‌پول، به‌جای دوازده.
  • بعد از هر ارسال موفق، موجودیِ نگه‌داشته‌شده دور ریخته می‌شود (forget_blockcypher_balance)، چون عددِ پیش از ارسال دیگر درست نیست.
  • خود ارسال دیگر موجودی نمی‌خواند. هر پرداخت دقیقاً دو درخواست است: txs/new و txs/send.
  • هر خطا یک خط است: فقط حرف BlockCypher برداشته می‌شود، نه کل اسکلت تراکنش با ورودی‌ها و خروجی‌هایش.

ارسالی که شاید رفته، نگه داشته می‌شود و دوباره ساخته نمی‌شود

این مهم‌ترین بخش بود، چون «ارسال نشد» یک حالت نیست. اگر txs/send به BlockCypher رسیده و جوابش گم شده باشد (timeout، قطع اتصال)، یا پاسخ 5xx بیاید، معلوم نیست تراکنش پخش شده یا نه. اگر چنین پرداختی در صف بماند، tick بعدی تراکنش دومی می‌سازد، و وقتی اولی mine شود، کاربر دو بار پول گرفته است.

چه شدفرستنده چه برمی‌گرداندپرداخت‌کننده چه می‌کند
txs/send با موفقیتTrue و هش تراکنشبرداشت را تمام می‌کند
شکست txs/new، یا 4xx روی txs/send (ورودی قفل، سقف)False و بدون هشبرداشت را در صف نگه می‌دارد
خطای شبکه روی txs/send، یا 5xxFalse، هش اسکلت و maybe_sentبرداشت را روی همان هش پارک می‌کند
# For a segwit spend the hash does not change with the signatures.
skeleton_hash = (tx_skel.get("tx") or {}).get("hash")
# ... sign each of tx_skel["tosign"] ...
try:
    r2 = requests.post(f"{base_url}/txs/send", json=tx_skel, ...)
except requests.RequestException as e:
    # It may have reached BlockCypher and only its answer been lost.
    return (
        False,
        skeleton_hash,
        {"stage": "send", "error": redact(e), "maybe_sent": True},
    )
res = _body(r2)
if r2.status_code >= 500:
    return (
        False,
        skeleton_hash,
        {"stage": "send", "error": _errors_of(res), "maybe_sent": True},
    )
if r2.status_code >= 400 or "errors" in res or "error" in res:
    # Refused, not sent: an input locked by another transaction, the limit.
    if _is_over_the_limit(r2.status_code, res):
        pause_blockcypher()
    return False, None, {"stage": "send", "error": _errors_of(res)}

هش اسکلت از جواب txs/new می‌آید. فرستنده برای کیف‌پول BIP84 (bech32) نوشته شده، و در خرج segwit امضاها در witness می‌نشینند، بیرون از داده‌ای که txid از آن ساخته می‌شود؛ پس هش اسکلت همان هشی است که تراکنش، اگر mine شود، خواهد داشت. برای ورودی legacy این درست نیست.

send_btc و send_ltc این هش را همراه شکست بالا می‌دهند و پرداخت‌کننده برداشت را روی آن پارک می‌کند. در پروژه‌ای که این صف را اجرا می‌کنم، تطبیق خودکار رسید فقط برای زنجیره‌های EVM نوشته شده؛ پرداخت بیت‌کوینیِ پارک‌شده می‌ماند تا ادمین هش را در explorer ببیند و دستی تکلیفش را روشن کند. چند دقیقه وقت ادمین ارزان‌تر از پرداخت دوباره است.

یک پرداخت در هر زمان روی هر زنجیره

BLOCKCYPHER_PAYOUT_CHAINS زنجیره‌هایی را نام می‌برد که BlockCypher پرداخت‌شان را از سکه‌های یک کیف‌پول می‌سازد: بیت‌کوین، لایت‌کوین و دوج‌کوین. پرداخت‌کننده روی این زنجیره‌ها پیش از ارسال یک نوبت در cache می‌گیرد:

SERIAL_PAYOUT_GAP_SECONDS = 4 * 60


def reserve_serial_payout(chain) -> bool:
    if chain not in cs.BLOCKCYPHER_PAYOUT_CHAINS:
        return True
    return cache.add(SERIAL_PAYOUT_KEY.format(chain), True, SERIAL_PAYOUT_GAP_SECONDS)


# ... in pay_withdrawal, after the send:
if result:
    complete_withdrawal(withdrawal, txid)
    return True
if txid:
    # Broadcast but unconfirmed. Held, never re-sent.
    park_withdrawal_in_flight(withdrawal, chain, txid, msg)
    return False
# Nothing went out: the request keeps its place in the queue.
log_withdrawal_error(withdrawal, f"Payment service failed: {msg}")
return False

cache.add فقط وقتی می‌نویسد که کلید نباشد، پس از چند task هم‌زمان فقط یکی برنده می‌شود. نوبت پس داده نمی‌شود و چهار دقیقه بعد خودش منقضی می‌شود. با beat پنج‌دقیقه‌ای یعنی در هر tick یک پرداخت روی هر زنجیره، و پرداخت بعدی باقی‌مانده‌ی (change) پرداخت قبلی را در کیف‌پول می‌بیند، به‌جای این‌که روی همان ورودی قفل بخورد.

تست‌ها چه چیزی را ثابت نگه می‌دارند

tests/test_blockcypher_limits.py هیچ درخواست واقعی نمی‌فرستد: requests.get و requests.post با MagicMock عوض می‌شوند و توکن یک مقدار ساختگی است. برای هر اصلاحِ بالا تستی هست که تعداد فراخوانی‌ها را می‌شمارد یا خروجی برگشتی را چک می‌کند: توکن نه در خطای نگه‌داشته‌شده هست و نه در خروجی capsys؛ بعد از یک 429 دیگر چیزی پرسیده نمی‌شود (get.call_count == 1)؛ هر پرداخت دو POST است و هیچ GET؛ ارسالِ ردشده هشی ندارد، ولی timeout یا 502 هش اسکلت را برمی‌گرداند. یکی از آن‌ها:

def test_a_send_whose_answer_was_lost_hands_its_hash_back():
    """Perhaps sent: a payout built again would pay twice once this one mines."""
    (ok, tx_hash, detail), _post, _get = _send(
        [_answer(201, SKELETON), requests.Timeout(f"read timed out ?token={TOKEN}")]
    )

    assert (ok, tx_hash) == (False, SKELETON_HASH)
    assert detail["maybe_sent"] is True
    assert TOKEN not in detail["error"]

درس‌ها

  • دور هر API که پول جابه‌جا می‌کند، «شکست» سه حالت دارد: رفت، رد شد، نمی‌دانم. «رد شد» را دوباره امتحان کنید؛ «نمی‌دانم» را نگه دارید، روی شناسه‌ای که پیش از قدمِ برگشت‌ناپذیر گرفته‌اید، این‌جا هش اسکلت.
  • رازی که در URL است، به لاگ می‌رسد. کتابخانه‌های HTTP آدرس را در خطایشان می‌نویسند و آن خطا بالا می‌رود تا جایی که کسی می‌خواندش. در مرز client پاکش کنید و تستی بگذارید که پاک بماند.
  • سهمیه وضعیت مشترک است. جایش در cache مشترک است، نه در حافظه‌ی یک worker؛ و وقتی سرویس گفت بس است، تا چرخیدن پنجره‌اش بس کنید، چون امتحان دوباره خودِ پنجره را می‌سوزاند.
  • درخواست‌های هر عملیات را بشمارید و عددش را در تست ثابت کنید. یک print از موجودی بی‌خطر به نظر می‌رسد، تا وقتی معلوم شود یکی از صد درخواستِ ساعت است.