Skip to content
Open
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
55 changes: 46 additions & 9 deletions node/airdrop_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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


Expand Down
87 changes: 87 additions & 0 deletions tests/test_airdrop_tier_github_scope_8184.py
Original file line number Diff line number Diff line change
@@ -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'(?<!\\)#', line, maxsplit=1)[0]
cleaned.append(code_part)
return "".join(cleaned)


class TestAirdropTierScope(unittest.TestCase):
def setUp(self):
self.module_path = os.path.join(
os.path.dirname(__file__), '..', 'node', 'airdrop_v2.py'
)
with open(self.module_path, 'r') as f:
self.raw_source = f.read()
self.source = _strip_comments_and_strings(self.raw_source)

def test_uses_search_issues_not_search_commits(self):
"""Must query /search/issues (PRs), not /search/commits."""
self.assertIn("/search/issues", self.source,
"Tier check should use the issues search endpoint for PRs")

def test_does_not_use_search_commits(self):
"""The old /search/commits endpoint must be gone from code."""
self.assertNotIn("api.github.com/search/commits", self.source,
"/search/commits counts commits, not merged PRs")

def test_query_is_scoped_to_org(self):
"""The search query must be scoped to the RustChain org."""
self.assertIn("org:Scottcjn", self.source,
"Contribution count must be scoped to the Scottcjn org, "
"not all of GitHub")

def test_query_uses_pr_and_merged_qualifiers(self):
"""The query must use is:pr is:merged qualifiers."""
self.assertIn("is:pr", self.source,
"Query must filter for pull requests")
self.assertIn("is:merged", self.source,
"Query must filter for merged PRs")

def test_no_bare_merged_true_qualifier(self):
"""The old bare 'merged:true' qualifier must be gone from code."""
self.assertNotIn("merged:true", self.source,
"The old 'merged:true' qualifier is a PR-search qualifier "
"that was misused on the commits endpoint")

def test_no_cloak_preview_accept_header(self):
"""The commits-search Accept header must be gone."""
self.assertNotIn("cloak-preview", self.source,
"The 'application/vnd.github.cloak-preview' header was "
"only needed for /search/commits")

def test_module_compiles(self):
"""The module must be syntactically valid."""
ast.parse(self.raw_source)


if __name__ == '__main__':
unittest.main()