-
Notifications
You must be signed in to change notification settings - Fork 11
feat: bacon token issuance — award 1 token per unique new domain reported, with Solana on-chain distribution #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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) { | ||
|
|
@@ -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++; | ||
|
|
@@ -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; | ||
|
|
@@ -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( | ||
|
|
@@ -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); | ||
|
|
||
| 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
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 */ } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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>`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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>`) | |
| : ""; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tokenMultiplieris computed viaBigInt(10 ** mintInfo.decimals), but10 ** decimalsis evaluated as a JSNumberfirst and can lose integer precision for largerdecimalsvalues (and thenBigInt(...)will lock in the wrong amount). Compute the power usingBigIntarithmetic (e.g.,10n ** BigInt(mintInfo.decimals)) to avoid precision issues.