diff --git a/node/airdrop_v2.py b/node/airdrop_v2.py index 83e4dae52..0419b489b 100644 --- a/node/airdrop_v2.py +++ b/node/airdrop_v2.py @@ -168,6 +168,20 @@ def to_dict(self) -> Dict[str, Any]: ).isoformat() return result + def to_public_dict(self) -> Dict[str, Any]: + return { + "lock_id": self.lock_id, + "from_chain": self.from_chain, + "to_chain": self.to_chain, + "amount_uwrtc": self.amount_uwrtc, + "amount_wrtc": self.amount_uwrtc / 1_000_000, + "timestamp": self.timestamp, + "timestamp_iso": datetime.fromtimestamp( + self.timestamp, tz=timezone.utc + ).isoformat(), + "status": self.status, + } + # ============================================================================ # Database Schema @@ -625,16 +639,20 @@ def _determine_tier( if user_resp.status_code != 200: return None - # Get contributions (PRs merged) - # Use GitHub search API for contributions + # Get merged PRs authored by this user, scoped to the + # RustChain org only. The old code hit /search/commits with + # a bare "author:X merged:true" query, which counts commits + # anywhere on GitHub — so any established account cleared the + # CORE (5+) tier without ever contributing here. + # + # /search/issues with is:pr is:merged is the correct endpoint + # for counting merged pull requests, and org:Scottcjn keeps the + # result bounded to this project's repos. contrib_resp = requests.get( - f"https://api.github.com/search/commits", - headers={ - **headers, - "Accept": "application/vnd.github.cloak-preview", - }, + "https://api.github.com/search/issues", + headers=headers, params={ - "q": f"author:{github_username} merged:true", + "q": f"author:{github_username} org:Scottcjn is:pr is:merged", "per_page": 1, }, timeout=10, @@ -1281,6 +1299,17 @@ def require_admin_key(): return jsonify({"ok": False, "error": "unauthorized"}), 401 return None + def has_admin_key(): + required = os.environ.get("RC_ADMIN_KEY", "").strip() + if not required: + return False + provided = ( + request.headers.get("X-Admin-Key") + or request.headers.get("X-API-Key") + or "" + ).strip() + return bool(provided) and hmac.compare_digest(provided, required) + def parse_json_object_body(require_body: bool = True): data = request.get_json(silent=True) if data is None: @@ -1474,6 +1503,12 @@ def create_bridge_lock(): 400, ) + # ── Admin auth: gate bridge lock creation ───────────────────────────── + auth_error = require_admin_key() + if auth_error: + return auth_error + # ── Auth passed ────────────────────────────────────────────────────── + amount_uwrtc = int(round(amount_wrtc * 1_000_000)) success, message, lock = airdrop.create_bridge_lock( @@ -1538,7 +1573,9 @@ def get_bridge_lock(lock_id: str): """Get bridge lock status.""" lock = airdrop.get_lock(lock_id) if lock: - return jsonify({"ok": True, "lock": lock.to_dict()}) + if has_admin_key(): + return jsonify({"ok": True, "lock": lock.to_dict()}) + return jsonify({"ok": True, "lock": lock.to_public_dict()}) return jsonify({"ok": False, "error": "lock_not_found"}), 404 diff --git a/tests/test_airdrop_tier_github_scope_8184.py b/tests/test_airdrop_tier_github_scope_8184.py new file mode 100644 index 000000000..662a2e9c3 --- /dev/null +++ b/tests/test_airdrop_tier_github_scope_8184.py @@ -0,0 +1,87 @@ +""" +Tests for airdrop tier scoping fix (Issue #8184). + +_determine_tier() used to count GitHub commits *anywhere* on GitHub, +so any established account qualified for the top tier (CORE = 200 wRTC) +without ever contributing to RustChain. + +These tests verify the fix scopes the query to merged PRs within the +Scottcjn org and uses the correct API endpoint. +""" +import ast +import re +import unittest +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def _strip_comments_and_strings(source: str) -> str: + """Strip comments and string literals so assertions target actual code.""" + tree = ast.parse(source) + # Collect lines that are purely comments + lines = source.splitlines(keepends=True) + # Remove full-line comments and inline comments + cleaned = [] + for line in lines: + stripped = line.lstrip() + if stripped.startswith("#"): + continue + # Remove inline comment (naive but sufficient for our check) + code_part = re.split(r'(?