diff --git a/README.md b/README.md index a827adf..3df0f60 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,16 @@ This hackathon by Locus provided the exact infrastructure to solve this. So, I b ### 2. The Solution (Human & Machine) 1. Contributors search for open bounties. 2. They open a PR linking the issue (e.g., `Fixes #123`, `Closes #123`). Bountic detects `pull_request.opened` and marks the PR as competing. -3. **For AI Agents / Web3 Users:** Contributors embed their Locus wallet address directly in the PR markdown using a hidden comment: - `` +3. **For AI Agents / Web3 Users:** Contributors embed their Locus wallet address directly in the PR markdown using a hidden comment: + `` + Multi-contributor PRs can provide separate wallets with named tags: + `` ### 3. Merge & Settle (Payout) 1. The maintainer merges the winning PR (`pull_request.closed`). 2. The Bountic escrow locks. The bot posts a final status update on the issue. 3. The maintainer clicks the Bountic dashboard link (or replies `/approve` on GitHub) to authorize the release of funds. -4. Bountic executes the payout via the **Locus API**, sends the USDC, and updates the GitHub issue to `PAID`. +4. Bountic executes the payout via the **Locus API**, splits USDC evenly across unique PR commit authors when multiple contributors are present, and updates the GitHub issue to `PAID`. --- @@ -136,4 +138,4 @@ LOCUS_WEBHOOK_SECRET= **3. Start the Development Server** ```bash npm run dev -``` \ No newline at end of file +``` diff --git a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx index b72106c..c0bb896 100644 --- a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx +++ b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx @@ -26,10 +26,12 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) { startTransition(async () => { try { const response = await approveBounty({ owner, repo, issueNumber }); - const { payoutType, recipientEmail, recipientWallet } = response.payout; + const { payoutType, payouts, recipientEmail, recipientWallet } = response.payout; let message = ""; - if (payoutType === "wallet" && recipientWallet) { + if (payoutType === "split" && payouts?.length) { + message = `Payout split across ${payouts.length} contributors.`; + } else if (payoutType === "wallet" && recipientWallet) { message = `Payout sent to wallet ${recipientWallet.slice(0, 6)}...${recipientWallet.slice(-4)}`; } else if (payoutType === "email" && recipientEmail) { message = `Payout sent to ${recipientEmail}`; diff --git a/lib/api/client.ts b/lib/api/client.ts index d78c429..f92b08b 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -160,11 +160,20 @@ export async function approveBounty(params: { issueId: string; amount: number; recipient: string; - payoutType: "wallet" | "email" | "unclaimed"; + payoutType: "wallet" | "email" | "unclaimed" | "split"; recipientEmail: string | null; recipientWallet: string | null; txHash: string | null; transactionId: string; + payouts?: Array<{ + recipientUsername: string; + amount: number; + payoutType: "wallet" | "email" | "unclaimed"; + recipientEmail?: string | null; + recipientWallet?: string | null; + txHash: string | null; + transactionId: string; + }>; approvedBy: string; }; }> { diff --git a/lib/bounty/services/approve-payout.ts b/lib/bounty/services/approve-payout.ts index 934f48c..71599bb 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -1,11 +1,67 @@ import "server-only"; -import { resolveAndPayout } from "@/lib/bounty/services/payout"; +import { resolveAndPayoutMany } from "@/lib/bounty/services/payout"; import { syncGithubBountyArtifacts } from "@/lib/bounty/services/github-sync"; import { getSupabaseServiceClient } from "@/lib/clients/supabase/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; import { buildIssueId } from "@/lib/bounty/issue-id"; +type GithubInstallationClient = Awaited>; + +function splitAmountEvenly(amount: number, recipientUsernames: string[]) { + const totalCents = Math.round(amount * 100); + const baseCents = Math.floor(totalCents / recipientUsernames.length); + let remainderCents = totalCents - baseCents * recipientUsernames.length; + + return recipientUsernames.map((username) => { + const cents = baseCents + (remainderCents > 0 ? 1 : 0); + remainderCents -= 1; + + return { + username, + amount: cents / 100, + }; + }); +} + +async function getMergedPrPayoutContext(params: { + github: GithubInstallationClient; + owner: string; + repo: string; + pullNumber: number; + fallbackAuthor: string; +}) { + const prResponse = await params.github.rest.pulls.get({ + owner: params.owner, + repo: params.repo, + pull_number: params.pullNumber, + }); + + const commits = await params.github.paginate(params.github.rest.pulls.listCommits, { + owner: params.owner, + repo: params.repo, + pull_number: params.pullNumber, + per_page: 100, + }); + + const contributors = new Set(); + for (const commit of commits) { + const login = commit.author?.login; + if (login) { + contributors.add(login); + } + } + + if (contributors.size === 0) { + contributors.add(params.fallbackAuthor); + } + + return { + body: prResponse.data.body ?? null, + contributors: [...contributors], + }; +} + export async function approveBountyPayout(params: { owner: string; repo: string; @@ -42,31 +98,40 @@ export async function approveBountyPayout(params: { } let winningPrBody: string | null = null; + let payoutRecipients = splitAmountEvenly(bounty.total_amount, [bounty.winning_pr_author]); + if (bounty.winning_pr_number) { try { const installationId = await getGithubRepoInstallationId(params.owner, params.repo); const github = await getGithubInstallationClient(installationId); - const prResponse = await github.rest.pulls.get({ + const prContext = await getMergedPrPayoutContext({ + github, owner: params.owner, repo: params.repo, - pull_number: bounty.winning_pr_number, + pullNumber: bounty.winning_pr_number, + fallbackAuthor: bounty.winning_pr_author, }); - winningPrBody = prResponse.data.body ?? null; + winningPrBody = prContext.body; + payoutRecipients = splitAmountEvenly(bounty.total_amount, prContext.contributors); } catch (err) { - console.warn("Failed to fetch PR body for wallet extraction:", err); + console.warn("Failed to fetch PR payout context; falling back to PR author:", err); } } - // only single payout for now, later multiple payouts - const payoutResult = await resolveAndPayout({ + const payoutResults = await resolveAndPayoutMany({ owner: params.owner, repo: params.repo, issueNumber: params.issueNumber, - winningPrAuthor: bounty.winning_pr_author, winningPrBody, - amount: bounty.total_amount, + recipients: payoutRecipients, issueId, }); + if (payoutResults.length === 0) { + throw new Error("No payout recipients were resolved"); + } + + const payoutTxHashes = payoutResults.map((payout) => payout.txHash).filter((txHash): txHash is string => Boolean(txHash)); + const primaryPayout = payoutResults[0]; const now = new Date().toISOString(); @@ -74,7 +139,7 @@ export async function approveBountyPayout(params: { .from("bounties") .update({ status: "PAID", - payout_tx_hash: payoutResult.txHash, + payout_tx_hash: payoutTxHashes[0] ?? null, paid_at: now, approved_by: params.approvedBy, }) @@ -84,21 +149,24 @@ export async function approveBountyPayout(params: { throw new Error(`Failed to update bounty status to PAID: ${updateError.message}`); } - const { error: payoutEventError } = await supabase.from("payout_events").insert({ - issue_id: issueId, - recipient_username: bounty.winning_pr_author, - amount: bounty.total_amount, - locus_transaction_id: payoutResult.transactionId, - transaction_hash: payoutResult.txHash, - status: "SUCCESS", - metadata: { - approved_by: params.approvedBy, - payout_source: "web", - payout_type: payoutResult.payoutType, - recipient_email: payoutResult.recipientEmail, - recipient_wallet: payoutResult.recipientWallet, - }, - }); + const { error: payoutEventError } = await supabase.from("payout_events").insert( + payoutResults.map((payout) => ({ + issue_id: issueId, + recipient_username: payout.recipientUsername, + amount: payout.amount, + locus_transaction_id: payout.transactionId, + transaction_hash: payout.txHash, + status: "SUCCESS" as const, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: payout.payoutType, + recipient_email: payout.recipientEmail, + recipient_wallet: payout.recipientWallet, + split_count: payoutResults.length, + }, + })), + ); if (payoutEventError) { throw new Error(`Failed to persist payout event: ${payoutEventError.message}`); @@ -107,13 +175,22 @@ export async function approveBountyPayout(params: { const { error: activityError } = await supabase.from("activity_events").insert({ issue_id: issueId, event_type: "PAYOUT_SENT", - actor_username: bounty.winning_pr_author, + actor_username: params.approvedBy, amount: bounty.total_amount, - tx_hash: payoutResult.txHash, + tx_hash: payoutTxHashes[0] ?? null, metadata: { approved_by: params.approvedBy, payout_source: "web", - payout_type: payoutResult.payoutType, + payout_type: payoutResults.length === 1 ? primaryPayout.payoutType : "split", + payouts: payoutResults.map((payout) => ({ + recipient: payout.recipientUsername, + amount: payout.amount, + payout_type: payout.payoutType, + recipient_email: payout.recipientEmail, + recipient_wallet: payout.recipientWallet, + tx_hash: payout.txHash, + transaction_id: payout.transactionId, + })), }, }); @@ -126,12 +203,13 @@ export async function approveBountyPayout(params: { return { issueId, amount: bounty.total_amount, - recipient: bounty.winning_pr_author, - payoutType: payoutResult.payoutType, - recipientEmail: payoutResult.recipientEmail, - recipientWallet: payoutResult.recipientWallet, - txHash: payoutResult.txHash, - transactionId: payoutResult.transactionId, + recipient: payoutResults.length === 1 ? primaryPayout.recipientUsername : `${payoutResults.length} contributors`, + payoutType: payoutResults.length === 1 ? primaryPayout.payoutType : "split", + recipientEmail: payoutResults.length === 1 ? primaryPayout.recipientEmail : null, + recipientWallet: payoutResults.length === 1 ? primaryPayout.recipientWallet : null, + txHash: payoutTxHashes[0] ?? null, + transactionId: payoutResults.length === 1 ? primaryPayout.transactionId : `split_${Date.now()}`, + payouts: payoutResults, approvedBy: params.approvedBy, }; -} \ No newline at end of file +} diff --git a/lib/bounty/services/payout.ts b/lib/bounty/services/payout.ts index 3f40a1c..324a687 100644 --- a/lib/bounty/services/payout.ts +++ b/lib/bounty/services/payout.ts @@ -6,6 +6,8 @@ import { getSupabaseServerEnv } from "@/lib/env/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; const BOUNTIC_ADDRESS_REGEX = //i; +const BOUNTIC_NAMED_ADDRESS_REGEX = + //gi; export type PayoutResult = { transactionId: string; @@ -15,8 +17,29 @@ export type PayoutResult = { recipientWallet?: string | null; }; -function extractWalletFromPrBody(prBody: string | null): string | null { +export type RecipientPayoutResult = PayoutResult & { + recipientUsername: string; + amount: number; +}; + +function extractWalletFromPrBody( + prBody: string | null, + githubUsername: string, + allowGenericWallet: boolean, +): string | null { if (!prBody) return null; + + for (const match of prBody.matchAll(BOUNTIC_NAMED_ADDRESS_REGEX)) { + const [, matchedUsername, walletAddress] = match; + if (matchedUsername.toLowerCase() === githubUsername.toLowerCase()) { + return walletAddress; + } + } + + if (!allowGenericWallet) { + return null; + } + const match = BOUNTIC_ADDRESS_REGEX.exec(prBody); return match ? match[1] : null; } @@ -145,8 +168,13 @@ export async function resolveAndPayout(params: { winningPrBody: string | null; amount: number; issueId: string; + allowGenericWallet?: boolean; }): Promise { - const walletFromPr = extractWalletFromPrBody(params.winningPrBody); + const walletFromPr = extractWalletFromPrBody( + params.winningPrBody, + params.winningPrAuthor, + params.allowGenericWallet ?? true, + ); const recipientEmail = await getRecipientEmail(params.winningPrAuthor); if (walletFromPr) { @@ -173,4 +201,40 @@ export async function resolveAndPayout(params: { amount: params.amount, issueId: params.issueId, }); -} \ No newline at end of file +} + +export async function resolveAndPayoutMany(params: { + owner: string; + repo: string; + issueNumber: number; + issueId: string; + winningPrBody: string | null; + recipients: Array<{ + username: string; + amount: number; + }>; +}): Promise { + const allowGenericWallet = params.recipients.length === 1; + const payouts: RecipientPayoutResult[] = []; + + for (const recipient of params.recipients) { + const payout = await resolveAndPayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + issueId: params.issueId, + winningPrAuthor: recipient.username, + winningPrBody: params.winningPrBody, + amount: recipient.amount, + allowGenericWallet, + }); + + payouts.push({ + ...payout, + recipientUsername: recipient.username, + amount: recipient.amount, + }); + } + + return payouts; +} diff --git a/public/LLM.md b/public/LLM.md index d24cb53..5506a70 100644 --- a/public/LLM.md +++ b/public/LLM.md @@ -154,11 +154,22 @@ Response: "issueId": "owner/repo#123", "amount": 0, "recipient": "...", - "payoutType": "wallet|email|unclaimed", + "payoutType": "wallet|email|unclaimed|split", "recipientEmail": null, "recipientWallet": null, "txHash": null, "transactionId": "...", + "payouts": [ + { + "recipientUsername": "contributor", + "amount": 0, + "payoutType": "wallet|email|unclaimed", + "recipientEmail": null, + "recipientWallet": null, + "txHash": null, + "transactionId": "..." + } + ], "approvedBy": "github_username" } } @@ -211,7 +222,14 @@ Body: ``` -The tag must be present in the PR body exactly once. It should be parseable via regex. +The generic tag is used only when a merged PR has one payout recipient. + +- Named wallet payout tags for multi-contributor PRs: +``` + +``` + +When a merged PR includes commits from multiple GitHub users, Bountic splits the bounty evenly across the unique commit authors. Named tags let each contributor provide a separate wallet address. ## State Rules