From ae701884f031d35582d8bb03d98bc26afdd46560 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:17:52 +0800 Subject: [PATCH 1/2] fix: preserve valid Unicode wallet asset codes Co-Authored-By: Codex --- application/account_facts.py | 24 +++++--- tests/test_binance_account_facts.py | 86 ++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/application/account_facts.py b/application/account_facts.py index 4c61b9ff..5f138333 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -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 @@ -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): @@ -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") @@ -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 @@ -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")] diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 4680bdfa..edca82b1 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -446,7 +446,7 @@ def get_account(self): ({"uid": "123456", "balances": ["synthetic-private-row"]}, "spot_balance_row_invalid"), ({"uid": "123456", "balances": [{"asset": 17}]}, "spot_asset_type_invalid"), ({"uid": "123456", "balances": [{"asset": "synthetic-bad-symbol"}]}, "spot_asset_format_invalid"), - ({"uid": "123456", "balances": [{"asset": "币"}]}, "spot_asset_non_ascii"), + ({"uid": "123456", "balances": [{"asset": "A\u200b"}]}, "spot_asset_format_invalid"), ({"uid": "123456", "balances": [ {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, @@ -465,6 +465,90 @@ def get_account(self): assert client.calls == [("spot",)] +@pytest.mark.parametrize("asset", ["币", "12", "𐐀", "A" * 128, "汉" * 128]) +def test_unicode_asset_rules_preserve_spot_earn_and_payload_values(asset): + class UnicodeAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [{"asset": asset, "free": "0.5", "locked": "0"}], + } + + def get_simple_earn_flexible_product_position(self, *, current, size): + self.calls.append(("earn", current, size)) + return { + "rows": [{"asset": asset, "productId": "synthetic-product", "totalAmount": "0.25"}], + "total": 1, + } + + client = UnicodeAsset() + payload = _collect(client) + assert payload["assets"] == [{ + "asset": asset, + "quantity": "0.75", + "spot_free": "0.5", + "spot_locked": "0", + "flexible_earn": "0.25", + }] + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + round_tripped = json.loads(encoded) + assert round_tripped["assets"][0]["asset"] == asset + validate_account_facts_payload(round_tripped) + + +@pytest.mark.parametrize("asset", [ + "", " ", "btc", "A-B", "A\u0301", "A\u200b", "A\u202e", "A\ud800", "😀", "A" * 129, + "A\u115f", "A\u1160", "A\u3164", "A\uffa0", +]) +def test_unicode_asset_rules_reject_unsafe_or_out_of_contract_values(asset): + class InvalidAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [{"asset": asset, "free": "0.5", "locked": "0"}], + } + + with pytest.raises(AccountFactsUnavailable, match="spot_asset_format_invalid"): + _collect(InvalidAsset()) + + +@pytest.mark.parametrize("asset", ["A\u115f", "A\u1160", "A\u3164", "A\uffa0"]) +def test_unicode_earn_asset_rejects_invisible_hangul_fillers(asset): + client = FakeBinance(earn_pages=[{ + "rows": [{"asset": asset, "productId": "synthetic-product", "totalAmount": "0.25"}], + "total": 1, + }]) + with pytest.raises(AccountFactsUnavailable, match="flexible_earn_page_invalid"): + _collect(client) + assert client.calls == [("spot",), ("earn", 1, 100)] + + +@pytest.mark.parametrize("asset", ["A\u115f", "A\u1160", "A\u3164", "A\uffa0"]) +def test_payload_validator_rejects_invisible_hangul_fillers(asset): + payload = _collect() + payload["assets"][0]["asset"] = asset + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + +def test_exact_duplicate_unicode_asset_is_rejected_without_normalization(): + class DuplicateUnicodeAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [ + {"asset": "币", "free": "0.5", "locked": "0"}, + {"asset": "币", "free": "0.5", "locked": "0"}, + ], + } + + with pytest.raises(AccountFactsUnavailable, match="spot_asset_duplicate"): + _collect(DuplicateUnicodeAsset()) + + def test_rejects_earn_asset_without_an_explicit_spot_balance_row(): class SpotWithoutEarnAsset(FakeBinance): def get_account(self): From c8a8cb605e797d9b7baa51d4f9f2f2bce285d335 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:49:38 +0800 Subject: [PATCH 2/2] Classify private-safe account-facts publishing failures Co-Authored-By: Codex --- scripts/publish_binance_account_facts.py | 53 ++++++- tests/test_binance_account_facts.py | 179 +++++++++++++++++++++++ 2 files changed, 229 insertions(+), 3 deletions(-) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 936a7795..86dde13a 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -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): @@ -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") @@ -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"] diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index edca82b1..02f843b0 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -4,6 +4,7 @@ import json from datetime import datetime, timedelta, timezone from pathlib import Path +from urllib.error import URLError import pytest @@ -1095,3 +1096,181 @@ def open(self, _request, timeout): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) + + +@pytest.mark.parametrize(("status", "receiver_code", "safe_label"), [ + (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_observation_conflict", "http_409_observation_conflict"), + (413, "binance_account_facts_payload_too_large", "http_413_payload_too_large"), + (503, "binance_account_facts_binding_missing", "http_503_receiver_unavailable"), +]) +def test_publisher_classifies_only_known_http_error_pairs_without_leaking_body( + tmp_path, monkeypatch, status, receiver_code, safe_label +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + secret = "synthetic-sensitive-placeholder" + private_url = f"https://private.example/path?token={secret}" + reads = [] + + class Opener: + def open(self, _request, timeout): + assert timeout == 20 + error = HTTPError( + private_url, + status, + "private exception detail", + hdrs=None, + fp=BytesIO(json.dumps({"ok": False, "error": receiver_code}).encode()), + ) + original_read = error.read + + def tracked_read(amount=None): + reads.append(amount) + return original_read(amount) + + error.read = tracked_read + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) + assert str(raised.value) == f"account_facts_publish_{safe_label}" + assert receiver_code not in str(raised.value) + assert secret not in str(raised.value) + assert "private.example" not in str(raised.value) + assert reads == [4097] + + +@pytest.mark.parametrize(("status", "body"), [ + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}"), + (418, b"SENSITIVE private body"), + (409, b"x" * 4097), +]) +def test_publisher_rejects_unknown_or_oversized_http_error_body_closed( + tmp_path, monkeypatch, status, body +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + reads = [] + + class Opener: + def open(self, _request, timeout): + error = HTTPError( + "https://private.example/sensitive-path", + status, + "private exception detail", + hdrs=None, + fp=BytesIO(body), + ) + original_read = error.read + + def tracked_read(amount=None): + reads.append(amount) + return original_read(amount) + + error.read = tracked_read + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) + assert str(raised.value) == "account_facts_publish_http_rejected" + assert "SENSITIVE" not in str(raised.value) + assert "private.example" not in str(raised.value) + assert reads == [4097] + + +@pytest.mark.parametrize(("failure", "reason"), [ + (lambda: URLError("private network detail"), "account_facts_publish_network_failed"), + (lambda: OSError("private socket detail"), "account_facts_publish_network_failed"), + (lambda: TimeoutError("private timeout detail"), "account_facts_publish_timeout"), +]) +def test_publisher_classifies_transport_failures_without_retry_or_leak( + tmp_path, monkeypatch, failure, reason +): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + calls = [] + + class Opener: + def open(self, _request, timeout): + calls.append(timeout) + raise failure() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-secret", + }, + ) + assert str(raised.value) == reason + assert "private" not in str(raised.value) + assert "synthetic-secret" not in str(raised.value) + assert calls == [20] + + +@pytest.mark.parametrize("ack", [b"not-json", b"\xff\xfe"]) +def test_publisher_classifies_invalid_ack_without_leaking_or_retrying(tmp_path, monkeypatch, ack): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + calls = [] + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + assert limit == 4097 + return ack + + class Opener: + def open(self, _request, timeout): + calls.append(timeout) + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-secret", + }, + ) + assert str(raised.value) == "account_facts_publish_ack_invalid" + assert "synthetic-secret" not in str(raised.value) + assert calls == [20]