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
172 changes: 157 additions & 15 deletions .github/workflows/update-leaderboard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ jobs:
script: |
const fs = require('fs');

// Preserve distributed_tokens from previous run so on-chain history is not lost
let prevDistributed = {};
try {
const prevData = JSON.parse(fs.readFileSync('data/leaderboard.json', 'utf8'));
for (const entry of (prevData.leaderboard || [])) {
prevDistributed[entry.login] = entry.distributed_tokens || 0;
}
} catch { /* first run — no previous data */ }

// Fetch all issues labelled "bug" (paginate through all pages)
const issues = await github.paginate(
github.rest.issues.listForRepo,
Expand All @@ -69,7 +78,7 @@ jobs:
const bugIssues = issues.filter(i => !i.pull_request);

// Tally reporters and extract org names
const counts = {}; // login → { count, avatar_url, profile_url }
const counts = {}; // login → { count, avatar_url, profile_url, domains }
const orgSet = new Set();

for (const issue of bugIssues) {
Expand All @@ -80,6 +89,7 @@ jobs:
count: 0,
avatar_url: issue.user.avatar_url,
profile_url: issue.user.html_url,
domains: new Set(),
};
}
counts[login].count++;
Expand All @@ -91,18 +101,6 @@ jobs:
}
}

// Sort descending by report count
const leaderboard = Object.entries(counts)
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, 50)
.map(([login, data], idx) => ({
rank: idx + 1,
login,
count: data.count,
avatar_url: data.avatar_url,
profile_url: data.profile_url,
}));

// Extract domain from the URL field in an issue body
function extractDomain(body) {
if (!body) return null;
Expand All @@ -117,17 +115,60 @@ jobs:
try { return new URL(rawUrl).hostname || null; } catch { return null; }
}

// Build top-domains leaderboard
// Build top-domains leaderboard and track per-user unique domains for bacon tokens
const domainCounts = {};
for (const issue of bugIssues) {
const domain = extractDomain(issue.body);
if (domain) domainCounts[domain] = (domainCounts[domain] || 0) + 1;
if (domain) {
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
if (counts[issue.user.login]) {
counts[issue.user.login].domains.add(domain);
}
}
}
const topDomains = Object.entries(domainCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 20)
.map(([domain, count], idx) => ({ rank: idx + 1, domain, count }));

// Sort descending by report count; bacon_tokens = unique domains reported on
const leaderboardBase = Object.entries(counts)
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, 50)
.map(([login, data], idx) => ({
rank: idx + 1,
login,
count: data.count,
bacon_tokens: data.domains.size,
avatar_url: data.avatar_url,
profile_url: data.profile_url,
}));

// Fetch GitHub profile bios to extract Solana wallet addresses.
// A Solana public key is 32-44 base58 characters; require 43-44 to reduce false positives.
// Users can place their address anywhere in their bio, optionally prefixed with
// "sol:", "solana:", or "wallet:" (case-insensitive).
const SOLANA_ADDR_RE = /(?:sol(?:ana)?[:\s]+|wallet[:\s]+)?([1-9A-HJ-NP-Za-km-z]{43,44})\b/i;
const walletMap = {};
await Promise.all(
leaderboardBase.map(async ({ login }) => {
try {
const { data: user } = await github.rest.users.getByUsername({ username: login });
const bio = user.bio || '';
const match = bio.match(SOLANA_ADDR_RE);
walletMap[login] = match ? match[1] : null;
} catch {
walletMap[login] = null;
}
})
);

const leaderboard = leaderboardBase.map(entry => ({
...entry,
solana_wallet: walletMap[entry.login] || null,
distributed_tokens: prevDistributed[entry.login] || 0,
}));

// Build top-commenters leaderboard (fetch all issues' comments in parallel)
const commentCounts = {};
const allCommentArrays = await Promise.all(
Expand Down Expand Up @@ -231,3 +272,104 @@ jobs:
git pull --rebase
git push
fi

- name: Distribute BACON tokens on Solana
env:
SOLANA_DISTRIBUTOR_KEY: ${{ secrets.SOLANA_DISTRIBUTOR_KEY }}
SOLANA_BACON_MINT: ${{ secrets.SOLANA_BACON_MINT }}
SOLANA_RPC_URL: ${{ secrets.SOLANA_RPC_URL }}
run: |
if [ -z "$SOLANA_DISTRIBUTOR_KEY" ] || [ -z "$SOLANA_BACON_MINT" ]; then
echo "SOLANA_DISTRIBUTOR_KEY or SOLANA_BACON_MINT not configured — skipping Solana distribution"
exit 0
fi

# Install Solana packages into a temp directory to keep the repo clean
mkdir -p /tmp/bacon-dist
cd /tmp/bacon-dist
npm init -y --quiet
npm install --quiet "@solana/web3.js@1.98.4" "@solana/spl-token@0.4.14"

# Write the distribution script
cat > distribute.cjs << 'DIST_SCRIPT'
'use strict';
const { Connection, Keypair, PublicKey } = require('@solana/web3.js');
const { getOrCreateAssociatedTokenAccount, transfer, getMint } = require('@solana/spl-token');
const fs = require('fs');

async function main() {
const rpcUrl = process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
const connection = new Connection(rpcUrl, 'confirmed');

// Distributor keypair: base64-encoded JSON byte array stored as a GitHub secret
const keypairBytes = Buffer.from(process.env.SOLANA_DISTRIBUTOR_KEY, 'base64');
const distributorKeypair = Keypair.fromSecretKey(
new Uint8Array(JSON.parse(keypairBytes.toString()))
);

const mintPubkey = new PublicKey(process.env.SOLANA_BACON_MINT);
const mintInfo = await getMint(connection, mintPubkey);
const tokenMultiplier = BigInt(10 ** mintInfo.decimals);
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokenMultiplier is computed via BigInt(10 ** mintInfo.decimals), but 10 ** decimals is evaluated as a JS Number first and can lose integer precision for larger decimals values (and then BigInt(...) will lock in the wrong amount). Compute the power using BigInt arithmetic (e.g., 10n ** BigInt(mintInfo.decimals)) to avoid precision issues.

Suggested change
const tokenMultiplier = BigInt(10 ** mintInfo.decimals);
const tokenMultiplier = 10n ** BigInt(mintInfo.decimals);

Copilot uses AI. Check for mistakes.

const leaderboardPath = process.env.LEADERBOARD_PATH;
const data = JSON.parse(fs.readFileSync(leaderboardPath, 'utf8'));

const distributorATA = await getOrCreateAssociatedTokenAccount(
connection, distributorKeypair, mintPubkey, distributorKeypair.publicKey
);

let anyUpdated = false;
for (const entry of data.leaderboard) {
if (!entry.solana_wallet) continue;
const pending = entry.bacon_tokens - (entry.distributed_tokens || 0);
if (pending <= 0) continue;

let recipientPubkey;
try {
recipientPubkey = new PublicKey(entry.solana_wallet);
} catch {
console.error(`Skipping ${entry.login}: invalid Solana address "${entry.solana_wallet}"`);
continue;
}

try {
const recipientATA = await getOrCreateAssociatedTokenAccount(
connection, distributorKeypair, mintPubkey, recipientPubkey
);
await transfer(
connection,
distributorKeypair,
distributorATA.address,
recipientATA.address,
distributorKeypair.publicKey,
BigInt(pending) * tokenMultiplier
);
Comment on lines +322 to +346
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The distribution loop assumes entry.bacon_tokens is always a finite integer; if it’s missing/invalid in leaderboard.json, pending becomes NaN and BigInt(pending) will throw and fail the whole step. Consider validating/coercing bacon_tokens/distributed_tokens (e.g., default to 0 and Number.isFinite/Number.isInteger checks) before computing pending and converting to BigInt.

Copilot uses AI. Check for mistakes.
entry.distributed_tokens = entry.bacon_tokens;
anyUpdated = true;
console.log(`Sent ${pending} BACON → ${entry.login} (${entry.solana_wallet})`);
} catch (err) {
console.error(`Failed to send ${pending} BACON to ${entry.login} (${entry.solana_wallet}): ${err.message}`);
}
}

if (anyUpdated) {
fs.writeFileSync(leaderboardPath, JSON.stringify(data, null, 2) + '\n');
console.log('Updated distributed_tokens in leaderboard.json');
}
}

main().catch(err => { console.error(err.message); process.exit(1); });
DIST_SCRIPT

LEADERBOARD_PATH="$GITHUB_WORKSPACE/data/leaderboard.json" node distribute.cjs

# Commit updated distributed_tokens if anything changed
cd "$GITHUB_WORKSPACE"
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add data/leaderboard.json
if ! git diff --staged --quiet; then
git commit -m "chore: record BACON token distributions [skip ci]"
git pull --rebase
git push
fi
12 changes: 12 additions & 0 deletions data/leaderboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,39 @@
"rank": 1,
"login": "DonnieBLT",
"count": 6,
"bacon_tokens": 2,
"solana_wallet": null,
"distributed_tokens": 0,
"avatar_url": "https://avatars.githubusercontent.com/u/128622481?v=4",
"profile_url": "https://github.com/DonnieBLT"
},
{
"rank": 2,
"login": "kittenbytes",
"count": 1,
"bacon_tokens": 1,
"solana_wallet": null,
"distributed_tokens": 0,
"avatar_url": "https://avatars.githubusercontent.com/u/171991749?v=4",
"profile_url": "https://github.com/kittenbytes"
},
{
"rank": 3,
"login": "ananya-09",
"count": 1,
"bacon_tokens": 1,
"solana_wallet": null,
"distributed_tokens": 0,
"avatar_url": "https://avatars.githubusercontent.com/u/175581593?v=4",
"profile_url": "https://github.com/ananya-09"
},
{
"rank": 4,
"login": "sidd190",
"count": 1,
"bacon_tokens": 1,
"solana_wallet": null,
"distributed_tokens": 0,
"avatar_url": "https://avatars.githubusercontent.com/u/73955358?v=4",
"profile_url": "https://github.com/sidd190"
}
Expand Down
15 changes: 14 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ <h3 class="text-base font-bold text-gray-900 dark:text-gray-100">Fork-friendly p
<h3 class="text-base font-bold text-gray-900 dark:text-gray-100">Reporter revenue sharing</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">Commercial plan users can share a percentage of subscription value with contributing reporters.</p>
</article>

<article class="surface-card rounded-2xl p-5">
<div class="mb-3 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-50 dark:bg-yellow-950/30">
<span class="text-lg" aria-hidden="true">🥓</span>
</div>
<h3 class="text-base font-bold text-gray-900 dark:text-gray-100">Bacon token rewards</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">Earn one bacon token for every new domain you report on. Discover and report bugs across the web to grow your token balance.</p>
</article>
</div>
</div>
</section>
Expand Down Expand Up @@ -477,12 +485,13 @@ <h2 class="text-3xl font-extrabold text-gray-900 sm:text-4xl dark:text-gray-100"
<th scope="col" class="w-12 px-4 py-3 text-center">Rank</th>
<th scope="col" class="px-4 py-3">Reporter</th>
<th scope="col" class="px-4 py-3 text-right">Bugs</th>
<th scope="col" class="px-4 py-3 text-right">Bacon 🥓</th>
<th scope="col" class="hidden px-4 py-3 pr-6 text-right sm:table-cell">Activity</th>
</tr>
</thead>
<tbody id="leaderboard-rows" class="divide-y divide-neutral-border dark:divide-gray-700">
<tr>
<td colspan="4" class="py-12 text-center text-gray-400 dark:text-gray-500">
<td colspan="5" class="py-12 text-center text-gray-400 dark:text-gray-500">
<i class="fa-solid fa-circle-notch fa-spin mb-3 block text-2xl" aria-hidden="true"></i>
Loading leaderboard...
</td>
Expand All @@ -494,6 +503,10 @@ <h2 class="text-3xl font-extrabold text-gray-900 sm:text-4xl dark:text-gray-100"
<p class="mt-4 text-center text-xs text-gray-400 dark:text-gray-500">
Leaderboard refreshes every 6 hours via GitHub Actions.
<a href="https://github.com/OWASP-BLT/BLT-Pages/issues?q=label%3Abug" target="_blank" rel="noopener noreferrer" class="text-red-600 hover:underline dark:text-red-400">View all reports on GitHub</a>.
· 🥓 Bacon tokens are awarded for each unique new domain reported on.
· <i class="fa-solid fa-wallet text-purple-500" aria-hidden="true"></i> Add your Solana wallet address to your
<a href="https://github.com/settings/profile" target="_blank" rel="noopener noreferrer" class="text-purple-600 hover:underline dark:text-purple-400">GitHub bio</a>
to receive tokens on-chain.
</p>

<!-- Top Commenters -->
Expand Down
25 changes: 22 additions & 3 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ async function loadLeaderboardFromAPI(container, statBugs, statDomains, statRepo
for (const issue of issues) {
if (issue.pull_request) continue;
const user = issue.user.login;
counts[user] = (counts[user] || { count: 0, avatar_url: issue.user.avatar_url, profile_url: issue.user.html_url });
if (!counts[user]) {
counts[user] = { count: 0, avatar_url: issue.user.avatar_url, profile_url: issue.user.html_url, domains: new Set() };
}
counts[user].count++;

// Extract domain from URL field
Expand All @@ -221,6 +223,7 @@ async function loadLeaderboardFromAPI(container, statBugs, statDomains, statRepo
const domain = new URL(rawUrl).hostname;
if (domain) {
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
counts[user].domains.add(domain);
}
} catch { /* ignore malformed URLs */ }
}
Expand All @@ -229,7 +232,7 @@ async function loadLeaderboardFromAPI(container, statBugs, statDomains, statRepo
const leaderboard = Object.entries(counts)
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, 50)
.map(([login, data], idx) => ({ rank: idx + 1, login, ...data }));
.map(([login, data], idx) => ({ rank: idx + 1, login, count: data.count, bacon_tokens: data.domains.size, avatar_url: data.avatar_url, profile_url: data.profile_url }));

const topDomains = Object.entries(domainCounts)
.sort(([, a], [, b]) => b - a)
Expand Down Expand Up @@ -257,7 +260,7 @@ async function loadLeaderboardFromAPI(container, statBugs, statDomains, statRepo

function renderLeaderboard(container, data) {
if (!data.leaderboard || data.leaderboard.length === 0) {
container.innerHTML = `<tr><td colspan="4" class="text-center py-12 text-gray-500 dark:text-gray-400">
container.innerHTML = `<tr><td colspan="5" class="text-center py-12 text-gray-500 dark:text-gray-400">
<i class="fa-solid fa-trophy text-4xl text-gray-300 dark:text-gray-600 block mb-3" aria-hidden="true"></i>
No reports yet. Be the first to <a href="https://github.com/OWASP-BLT/BLT-Pages/issues/new?template=bug_report.yml" class="text-primary underline hover:no-underline">report a bug</a>!
</td></tr>`;
Expand All @@ -278,6 +281,16 @@ function renderLeaderboard(container, data) {
? "bg-active-bg dark:bg-red-900/10"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50";

const walletHtml = entry.solana_wallet
? `<span title="Solana wallet configured: ${escapeHtml(entry.solana_wallet.slice(0, 8))}…${escapeHtml(entry.solana_wallet.slice(-4))}" class="ml-1.5 text-xs text-purple-500 dark:text-purple-400">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<span class="sr-only">Solana wallet configured</span>
</span>`
: `<span title="Add your Solana wallet address to your GitHub bio to receive BACON tokens on-chain" class="ml-1.5 text-xs text-gray-300 dark:text-gray-600">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<span class="sr-only">No Solana wallet configured — add your address to your GitHub bio</span>
</span>`;
Comment on lines +284 to +292
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “no wallet configured” tooltip/sr-only text is shown whenever entry.solana_wallet is falsy. In the live GitHub API fallback path, solana_wallet is never populated, so this message will be displayed even for users who do have a wallet configured (it’s just unknown). Consider distinguishing between “missing” vs “not loaded” (e.g., hide the icon unless the field is present, or show a neutral “wallet not available in API mode” message).

Suggested change
const walletHtml = entry.solana_wallet
? `<span title="Solana wallet configured: ${escapeHtml(entry.solana_wallet.slice(0, 8))}${escapeHtml(entry.solana_wallet.slice(-4))}" class="ml-1.5 text-xs text-purple-500 dark:text-purple-400">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<span class="sr-only">Solana wallet configured</span>
</span>`
: `<span title="Add your Solana wallet address to your GitHub bio to receive BACON tokens on-chain" class="ml-1.5 text-xs text-gray-300 dark:text-gray-600">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<span class="sr-only">No Solana wallet configured — add your address to your GitHub bio</span>
</span>`;
const hasSolanaWalletField = Object.prototype.hasOwnProperty.call(
entry,
"solana_wallet",
);
const walletHtml = hasSolanaWalletField
? (entry.solana_wallet
? `<span title="Solana wallet configured: ${escapeHtml(entry.solana_wallet.slice(0, 8))}${escapeHtml(entry.solana_wallet.slice(-4))}" class="ml-1.5 text-xs text-purple-500 dark:text-purple-400">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<span class="sr-only">Solana wallet configured</span>
</span>`
: `<span title="Add your Solana wallet address to your GitHub bio to receive BACON tokens on-chain" class="ml-1.5 text-xs text-gray-300 dark:text-gray-600">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<span class="sr-only">No Solana wallet configured — add your address to your GitHub bio</span>
</span>`)
: "";

Copilot uses AI. Check for mistakes.

return `<tr class="${rowClass} transition-colors">
<td class="px-4 py-3 text-center w-12">${rankDisplay}</td>
<td class="px-4 py-3">
Expand All @@ -300,6 +313,12 @@ function renderLeaderboard(container, data) {
${formatNumber(entry.count)}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="inline-flex items-center gap-1 font-bold text-yellow-700 dark:text-yellow-400" title="Bacon tokens earned for reporting on unique new domains">
🥓 ${formatNumber(entry.bacon_tokens || 0)}
</span>
${walletHtml}
</td>
<td class="px-4 py-3 hidden sm:table-cell">
<div class="flex justify-end">
<div class="bg-gray-100 dark:bg-gray-700 rounded-full h-2 w-24 overflow-hidden">
Expand Down