Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions application/account_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import hashlib
import json
import re
import unicodedata
from collections.abc import Mapping
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, localcontext
Expand All @@ -28,7 +29,19 @@
MAX_EARN_POSITIONS = 10_000
_HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z")
_GIT_SHA = re.compile(r"[0-9a-f]{40}\Z")
_ASSET = re.compile(r"[A-Z0-9]{1,128}\Z")
_HANGUL_FILLERS = frozenset("\u115f\u1160\u3164\uffa0")


def _valid_asset(value: Any) -> bool:
if not isinstance(value, str) or not 1 <= len(value) <= 128:
return False
for character in value:
if character.isascii():
if not ("A" <= character <= "Z" or "0" <= character <= "9"):
return False
elif character in _HANGUL_FILLERS or unicodedata.category(character)[0] not in {"L", "N"}:
return False
return True


class AccountFactsUnavailable(ValueError):
Expand Down Expand Up @@ -189,9 +202,7 @@ def collect_account_facts(
asset = row.get("asset")
if not isinstance(asset, str):
raise _fail("spot_asset_type_invalid")
if not asset.isascii():
raise _fail("spot_asset_non_ascii")
if not _ASSET.fullmatch(asset):
if not _valid_asset(asset):
raise _fail("spot_asset_format_invalid")
if asset in assets:
raise _fail("spot_asset_duplicate")
Expand Down Expand Up @@ -237,8 +248,7 @@ def collect_account_facts(
asset = row.get("asset")
product_id = row.get("productId")
if (
not isinstance(asset, str)
or not _ASSET.fullmatch(asset)
not _valid_asset(asset)
or not isinstance(product_id, str)
or not product_id
or product_id in seen_products
Expand Down Expand Up @@ -383,7 +393,7 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]:
}:
raise _fail("payload_invalid")
asset = row.get("asset")
if not isinstance(asset, str) or not _ASSET.fullmatch(asset) or asset in seen_assets:
if not _valid_asset(asset) or asset in seen_assets:
raise _fail("payload_invalid")
seen_assets.add(asset)
amount_texts = [row.get(name) for name in ("spot_free", "spot_locked", "flexible_earn", "quantity")]
Expand Down
53 changes: 50 additions & 3 deletions scripts/publish_binance_account_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@


QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts"
_HTTP_ERROR_LABELS = {
(400, "invalid_binance_account_facts"): "http_400_report_invalid",
(400, "invalid_binance_account_facts_time"): "http_400_report_time_invalid",
(400, "invalid_binance_account_facts_quantity"): "http_400_report_quantity_invalid",
(400, "invalid_binance_account_facts_assets"): "http_400_report_assets_invalid",
(401, "binance_account_facts_token_invalid"): "http_401_token_invalid",
(409, "binance_account_facts_binding_unmatched"): "http_409_binding_unmatched",
(409, "binance_account_facts_identity_mismatch"): "http_409_identity_mismatch",
(409, "binance_account_facts_observation_invalid"): "http_409_observation_invalid",
(409, "binance_account_facts_observation_conflict"): "http_409_observation_conflict",
(413, "binance_account_facts_payload_too_large"): "http_413_payload_too_large",
(503, "binance_account_facts_token_unavailable"): "http_503_receiver_unavailable",
(503, "binance_account_facts_binding_missing"): "http_503_receiver_unavailable",
(503, "binance_account_facts_binding_invalid"): "http_503_receiver_unavailable",
(503, "binance_account_facts_storage_unavailable"): "http_503_receiver_unavailable",
(503, "binance_account_facts_unavailable"): "http_503_receiver_unavailable",
}
_HTTP_REJECTED = "account_facts_publish_http_rejected"


class PublishError(ValueError):
Expand All @@ -28,6 +46,23 @@ def redirect_request(self, request, response, code, message, headers, new_url):
return None


def _http_failure_code(error: HTTPError) -> str:
try:
raw = error.read(4097)
if not isinstance(raw, bytes) or len(raw) > 4096:
return _HTTP_REJECTED
body = json.loads(raw.decode("utf-8"))
if (not isinstance(body, dict) or set(body) != {"ok", "error"}
or body.get("ok") is not False or not isinstance(body.get("error"), str)):
return _HTTP_REJECTED
label = _HTTP_ERROR_LABELS.get((error.code, body["error"]))
if label is None:
return _HTTP_REJECTED
return f"account_facts_publish_{label}"
except (UnicodeError, ValueError, TypeError, RecursionError):
return _HTTP_REJECTED


def publish_account_facts(*, facts_path: Path, env) -> str:
if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true":
raise PublishError("account_facts_publish_disabled")
Expand Down Expand Up @@ -60,12 +95,24 @@ def publish_account_facts(*, facts_path: Path, env) -> str:
raise PublishError("account_facts_publish_rejected")
raw_ack = response.read(4097)
if len(raw_ack) > 4096:
raise PublishError("account_facts_publish_rejected")
raise PublishError("account_facts_publish_ack_invalid")
ack = json.loads(raw_ack.decode("utf-8"))
except PublishError:
raise
except (HTTPError, URLError, OSError, TimeoutError, UnicodeError, json.JSONDecodeError):
raise PublishError("account_facts_publish_failed") from None
except HTTPError as error:
try:
failure_code = _http_failure_code(error)
except TimeoutError:
raise PublishError("account_facts_publish_timeout") from None
except (URLError, OSError):
raise PublishError("account_facts_publish_network_failed") from None
raise PublishError(failure_code) from None
except TimeoutError:
raise PublishError("account_facts_publish_timeout") from None
except (URLError, OSError):
raise PublishError("account_facts_publish_network_failed") from None
except (UnicodeError, ValueError, TypeError, AttributeError, RecursionError):
raise PublishError("account_facts_publish_ack_invalid") from None
if not isinstance(ack, dict) or ack.get("status") not in {"published", "unchanged"}:
raise PublishError("account_facts_publish_rejected")
return ack["status"]
Expand Down
Loading
Loading