Skip to content

[Bug] _PR_TIMELINE_QUERY's closingIssuesReferences fragment omits repository field — cross-repo number collisions mis-attribute issue-bounty solvers and pay the wrong miner #1041

Description

@taillred

Description:

There are two closingIssuesReferences GraphQL fragments in gittensor/utils/github_api_tools.py:

  1. Line 91 — inside the main QUERY used by load_miners_prs for OSS-scoring. Open #1019 covers this site, with a fix in #1020 that adds repository { nameWithOwner } and drops nodes whose repo doesn't match.

  2. Line 426 — inside _PR_TIMELINE_QUERY used by _search_issue_referencing_prs_graphqlfind_solver_from_cross_referencescheck_github_issue_closed, which feeds the issue-bounty voting forward pass at gittensor/validator/issue_competitions/forward.py:90.

The fix in #1020 only touches site (1). Site (2) still emits nodes { number } without repository qualification, so the downstream solver lookup in find_solver_from_cross_references matches by issue number alone and can credit a PR that closes a same-numbered issue in a different repository as the solver of the bounty.

Source

gittensor/utils/github_api_tools.py:418-449_PR_TIMELINE_QUERY:

query($owner: String!, $name: String!, $issueNumber: Int!) {
  repository(owner: $owner, name: $name) {
    issue(number: $issueNumber) {
      timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 50) {
        nodes {
          ... on CrossReferencedEvent {
            source {
              ... on PullRequest {
                ...
                baseRepository { nameWithOwner }
                closingIssuesReferences(first: 20) {
                  nodes { number }                     ← no repository field
                }
                ...
              }
            }
          }
        }
      }
    }
  }
}

gittensor/utils/github_api_tools.py:502-505 — the parser drops the repo information that GraphQL would have provided:

closing = pr.get('closingIssuesReferences', {}).get('nodes', [])
closing_numbers = [n.get('number') for n in closing if n.get('number') is not None]

gittensor/utils/github_api_tools.py:1005 — solver candidate filter relies on the bare number list:

merged = [p for p in prs if p.get('state') == 'MERGED' and issue_number in p.get('closing_numbers', [])]

Reproduction

Set up a bounty for entrius/repo-A#42. A PR entrius/repo-A#100 is in repo-A's timeline (e.g. as a CROSS_REFERENCED_EVENT — body mentions repo-A#42 as related context), but its actual closing reference points to other-org/repo-B#42 via the cross-repo close syntax Closes other-org/repo-B#42.

  1. _search_issue_referencing_prs_graphql('entrius/repo-A', 42, ...) returns PR Fix: check validator thread in main loop #100 in its result list — baseRepository = entrius/repo-A passes the line 491 filter.
  2. The PR's closingIssuesReferences.nodes returns [{number: 42}] — the GraphQL fragment didn't include the repo, so the parser sees just the bare number.
  3. closing_numbers = [42]. The downstream filter 42 in [42] matches.
  4. find_solver_from_cross_references returns (author_id_of_PR_100, 100) as the canonical solver of entrius/repo-A#42.
  5. check_github_issue_closed returns solver_lookup_failed=False, solver_github_id=author_of_100, pr_number=100.
  6. forward.py:149-155 calls vote_solution(issue_id, hotkey_for(author_of_100), coldkey_for(author_of_100), pr_number=100, …).
  7. On consensus, the bounty pays out to the wrong solver — the miner who closed the other repo's Add comprehensive test suite for scoring module #42, not entrius/repo-A#42.

Why this matters

  • Real ALPHA payout to the wrong recipient. Like find_solver_from_cross_references's null-merged_at failure mode, this fires inside the on-chain consensus path; a wrong vote is irreversible once enough validators agree.
  • Asymmetry with #1019 / #1020. That fix patches the OSS-scoring path's GraphQL fragment but leaves the bounty-voting path's identical-shape vulnerability open. The two fragments share the same name (closingIssuesReferences) but live in two distinct queries, so it's easy to miss the second.
  • Same root cause, broader consequence. OSS scoring caps individual miner inflation at the multiplier level. Bounty voting transfers ALPHA on consensus.

Suggested fix

Mirror #1020's fix on the second fragment:

closingIssuesReferences(first: 20) {
  nodes {
    number
    repository { nameWithOwner }
  }
}

Then in the parser at _search_issue_referencing_prs_graphql:

target_repo = repo.lower()
closing_numbers = [
    n.get('number')
    for n in closing
    if n.get('number') is not None
    and (n.get('repository') or {}).get('nameWithOwner', '').lower() == target_repo
]

This way closing_numbers only contains issue numbers whose recorded repository matches the issue we're resolving the solver for. Backwards-compat is preserved (existing tests exercise same-repo PRs whose closingIssuesReferences[*].repository.nameWithOwner will match).

3-axis prior-art

Clean across all three.

Test gap

No existing test in tests/utils/ (or anywhere) constructs a fixture where _PR_TIMELINE_QUERY's closingIssuesReferences returns an issue from a different repo than the one being resolved. The fix should add such a fixture and assert that find_solver_from_cross_references('repo-A', 42, …) returns (None, None) when the only matching candidate's closing_numbers entry actually belongs to repo-B#42.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions