From e1c7475bf2e4f6f7c6c3d25e6ddaa5a6bf8c8b91 Mon Sep 17 00:00:00 2001 From: Christayc <93091189+Christayc@users.noreply.github.com> Date: Sun, 31 May 2026 18:29:34 -0600 Subject: [PATCH] feat: support split bounty payouts --- PLAN.md | 5 +- README.md | 18 +- .../issues/[issueNumber]/approve-button.tsx | 24 +- lib/api/client.ts | 13 +- lib/bounty/services/approve-payout.ts | 77 +++-- lib/bounty/services/payout.ts | 268 ++++++++++++++++-- lib/clients/locus/mock.ts | 2 +- public/LLM.md | 31 +- 8 files changed, 370 insertions(+), 68 deletions(-) diff --git a/PLAN.md b/PLAN.md index e0afcc8..20e664e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,5 +128,6 @@ Goal: give logged-in users a reason to log in beyond payout eligibility. - Default payout is email-based via linked user email. - Agent tag `bountic-address` enables wallet-native payout path. -2. Future split suggestion (not in scope now) - - TODO: use an LLM to suggest a payout split based on diff/commit attribution. +2. Payout split suggestion + - Merged PRs can now split payouts by explicit `bountic-split` weights or by commit-author attribution when no explicit split is provided. + - Future improvement: use an LLM to propose a maintainer-editable split based on diff/commit attribution. diff --git a/README.md b/README.md index a827adf..35f4075 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,20 @@ 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: + ```md + + ``` +4. **For multi-contributor PRs:** Contributors can provide per-recipient wallet tags and an optional weighted split: + ```md + + + + ``` + If no explicit split is provided, Bountic splits the payout by GitHub commit-author attribution on the merged PR. ### 3. Merge & Settle (Payout) 1. The maintainer merges the winning PR (`pull_request.closed`). @@ -136,4 +148,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..ca9257b 100644 --- a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx +++ b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx @@ -13,6 +13,24 @@ type Props = { issueNumber: number; }; +function formatPayoutRecipient(recipient: { + recipientUsername: string; + amount: number; + payoutType: "wallet" | "email" | "unclaimed"; + recipientEmail?: string | null; + recipientWallet?: string | null; +}) { + if (recipient.payoutType === "wallet" && recipient.recipientWallet) { + return `@${recipient.recipientUsername}: $${recipient.amount.toFixed(2)} to ${recipient.recipientWallet.slice(0, 6)}...${recipient.recipientWallet.slice(-4)}`; + } + + if (recipient.payoutType === "email" && recipient.recipientEmail) { + return `@${recipient.recipientUsername}: $${recipient.amount.toFixed(2)} to ${recipient.recipientEmail}`; + } + + return `@${recipient.recipientUsername}: $${recipient.amount.toFixed(2)} unclaimed`; +} + export function ApproveButton({ owner, repo, issueNumber }: Props) { const router = useRouter(); const [isPending, startTransition] = useTransition(); @@ -26,10 +44,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, recipientEmail, recipientWallet, payouts } = response.payout; let message = ""; - if (payoutType === "wallet" && recipientWallet) { + if (payoutType === "split" && payouts?.length) { + message = `Split payout approved: ${payouts.map(formatPayoutRecipient).join("; ")}`; + } 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..9e3a087 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -93,6 +93,16 @@ export type FundResponse = { checkout_url: string; }; +export type PayoutRecipient = { + recipientUsername: string; + amount: number; + transactionId: string; + txHash: string | null; + payoutType: "wallet" | "email" | "unclaimed"; + recipientEmail?: string | null; + recipientWallet?: string | null; +}; + export async function fetchBounties(params: { status?: string; min_amount?: number; @@ -160,11 +170,12 @@ 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?: PayoutRecipient[]; approvedBy: string; }; }> { diff --git a/lib/bounty/services/approve-payout.ts b/lib/bounty/services/approve-payout.ts index 934f48c..606c58b 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -42,6 +42,7 @@ export async function approveBountyPayout(params: { } let winningPrBody: string | null = null; + let winningPrCommitAuthors: string[] = []; if (bounty.winning_pr_number) { try { const installationId = await getGithubRepoInstallationId(params.owner, params.repo); @@ -52,18 +53,29 @@ export async function approveBountyPayout(params: { pull_number: bounty.winning_pr_number, }); winningPrBody = prResponse.data.body ?? null; + + const commitsResponse = await github.rest.pulls.listCommits({ + owner: params.owner, + repo: params.repo, + pull_number: bounty.winning_pr_number, + per_page: 100, + }); + + winningPrCommitAuthors = commitsResponse.data + .map((commit) => commit.author?.login) + .filter((login): login is string => typeof login === "string" && login.length > 0); } catch (err) { - console.warn("Failed to fetch PR body for wallet extraction:", err); + console.warn("Failed to fetch PR details for payout resolution:", err); } } - // only single payout for now, later multiple payouts const payoutResult = await resolveAndPayout({ owner: params.owner, repo: params.repo, issueNumber: params.issueNumber, winningPrAuthor: bounty.winning_pr_author, winningPrBody, + winningPrCommitAuthors, amount: bounty.total_amount, issueId, }); @@ -84,38 +96,42 @@ 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( + payoutResult.recipients.map((recipient) => ({ + issue_id: issueId, + recipient_username: recipient.recipientUsername, + amount: recipient.amount, + locus_transaction_id: recipient.transactionId, + transaction_hash: recipient.txHash, + status: "SUCCESS" as const, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: recipient.payoutType, + recipient_email: recipient.recipientEmail, + recipient_wallet: recipient.recipientWallet, + }, + })), + ); if (payoutEventError) { throw new Error(`Failed to persist payout event: ${payoutEventError.message}`); } - const { error: activityError } = await supabase.from("activity_events").insert({ - issue_id: issueId, - event_type: "PAYOUT_SENT", - actor_username: bounty.winning_pr_author, - amount: bounty.total_amount, - tx_hash: payoutResult.txHash, - metadata: { - approved_by: params.approvedBy, - payout_source: "web", - payout_type: payoutResult.payoutType, - }, - }); + const { error: activityError } = await supabase.from("activity_events").insert( + payoutResult.recipients.map((recipient) => ({ + issue_id: issueId, + event_type: "PAYOUT_SENT" as const, + actor_username: recipient.recipientUsername, + amount: recipient.amount, + tx_hash: recipient.txHash, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: recipient.payoutType, + }, + })), + ); if (activityError) { throw new Error(`Failed to persist payout activity: ${activityError.message}`); @@ -132,6 +148,7 @@ export async function approveBountyPayout(params: { recipientWallet: payoutResult.recipientWallet, txHash: payoutResult.txHash, transactionId: payoutResult.transactionId, + payouts: payoutResult.recipients, 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..a681c00 100644 --- a/lib/bounty/services/payout.ts +++ b/lib/bounty/services/payout.ts @@ -6,21 +6,158 @@ import { getSupabaseServerEnv } from "@/lib/env/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; const BOUNTIC_ADDRESS_REGEX = //i; +const BOUNTIC_RECIPIENT_REGEX = + //gi; +const BOUNTIC_SPLIT_REGEX = //i; export type PayoutResult = { transactionId: string; txHash: string | null; - payoutType: "wallet" | "email" | "unclaimed"; + payoutType: "wallet" | "email" | "unclaimed" | "split"; recipientEmail?: string | null; recipientWallet?: string | null; }; +type SinglePayoutResult = Omit & { + payoutType: "wallet" | "email" | "unclaimed"; +}; + +export type PayoutRecipient = { + username: string; + amount: number; +}; + +export type SplitPayoutResult = SinglePayoutResult & { + recipientUsername: string; + amount: number; +}; + +export type PayoutResolution = PayoutResult & { + recipients: SplitPayoutResult[]; +}; + function extractWalletFromPrBody(prBody: string | null): string | null { if (!prBody) return null; const match = BOUNTIC_ADDRESS_REGEX.exec(prBody); return match ? match[1] : null; } +function extractRecipientWalletsFromPrBody(prBody: string | null): Map { + const wallets = new Map(); + + if (!prBody) { + return wallets; + } + + for (const match of prBody.matchAll(BOUNTIC_RECIPIENT_REGEX)) { + const username = match[1]?.toLowerCase(); + const wallet = match[2]; + + if (username && wallet) { + wallets.set(username, wallet); + } + } + + return wallets; +} + +function parseManualSplit(prBody: string | null): Array<{ username: string; weight: number }> { + if (!prBody) { + return []; + } + + const splitMatch = BOUNTIC_SPLIT_REGEX.exec(prBody); + if (!splitMatch?.[1]) { + return []; + } + + const weights = new Map(); + const entryRegex = /@?([a-zA-Z0-9-]+)\s*[:=]\s*(\d+(?:\.\d+)?)%?/g; + + for (const match of splitMatch[1].matchAll(entryRegex)) { + const username = match[1]; + const weight = Number.parseFloat(match[2]); + + if (username && Number.isFinite(weight) && weight > 0) { + weights.set(username, (weights.get(username) ?? 0) + weight); + } + } + + return [...weights.entries()].map(([username, weight]) => ({ username, weight })); +} + +function buildCommitWeightedSplit( + winningPrAuthor: string, + commitAuthors: string[] = [], +): Array<{ username: string; weight: number }> { + const weights = new Map(); + + for (const author of commitAuthors) { + const username = author.trim(); + + if (!username) { + continue; + } + + weights.set(username, (weights.get(username) ?? 0) + 1); + } + + if (weights.size === 0) { + weights.set(winningPrAuthor, 1); + } + + return [...weights.entries()].map(([username, weight]) => ({ username, weight })); +} + +function splitAmountByWeight( + totalAmount: number, + weightedRecipients: Array<{ username: string; weight: number }>, +): PayoutRecipient[] { + const totalCents = Math.round(totalAmount * 100); + const positiveRecipients = weightedRecipients.filter((recipient) => recipient.weight > 0); + const totalWeight = positiveRecipients.reduce((sum, recipient) => sum + recipient.weight, 0); + + if (totalCents <= 0 || positiveRecipients.length === 0 || totalWeight <= 0) { + return []; + } + + const exactShares = positiveRecipients.map((recipient, index) => { + const exactCents = (totalCents * recipient.weight) / totalWeight; + + return { + index, + username: recipient.username, + cents: Math.floor(exactCents), + remainder: exactCents - Math.floor(exactCents), + }; + }); + + let assignedCents = exactShares.reduce((sum, share) => sum + share.cents, 0); + const sortedByRemainder = [...exactShares].sort((a, b) => { + if (b.remainder !== a.remainder) { + return b.remainder - a.remainder; + } + + return a.index - b.index; + }); + + for (const share of sortedByRemainder) { + if (assignedCents >= totalCents) { + break; + } + + share.cents += 1; + assignedCents += 1; + } + + return exactShares + .filter((share) => share.cents > 0) + .map((share) => ({ + username: share.username, + amount: share.cents / 100, + })); +} + async function getRecipientEmail(githubUsername: string): Promise { const supabase = getSupabaseServiceClient(); const { data: user } = await supabase @@ -48,11 +185,66 @@ async function commentOnIssue(params: { }); } +async function resolveSinglePayout(params: { + owner: string; + repo: string; + issueNumber: number; + recipientUsername: string; + recipientWallet: string | null; + amount: number; + issueId: string; +}): Promise { + const recipientEmail = await getRecipientEmail(params.recipientUsername); + + if (params.recipientWallet) { + const result = await callLocusPayoutByWallet({ + toAddress: params.recipientWallet, + amount: params.amount, + memo: `Bountic payout for ${params.issueId}`, + }); + + return { + ...result, + recipientUsername: params.recipientUsername, + amount: params.amount, + }; + } + + if (recipientEmail) { + const result = await callLocusPayoutByEmail({ + toEmail: recipientEmail, + amount: params.amount, + memo: `Bountic payout for ${params.issueId}`, + }); + + return { + ...result, + recipientUsername: params.recipientUsername, + amount: params.amount, + }; + } + + const result = await handleUnclaimedPayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + winningPrAuthor: params.recipientUsername, + amount: params.amount, + issueId: params.issueId, + }); + + return { + ...result, + recipientUsername: params.recipientUsername, + amount: params.amount, + }; +} + export async function callLocusPayoutByEmail(params: { toEmail: string; amount: number; memo: string; -}): Promise { +}): Promise { const locus = getLocusServerClient(); try { @@ -85,7 +277,7 @@ export async function callLocusPayoutByWallet(params: { toAddress: string; amount: number; memo: string; -}): Promise { +}): Promise { const locus = getLocusServerClient(); const payload = await locus.request<{ @@ -115,7 +307,7 @@ export async function handleUnclaimedPayout(params: { winningPrAuthor: string; amount: number; issueId: string; -}): Promise { +}): Promise { const env = getSupabaseServerEnv(); await commentOnIssue({ @@ -143,34 +335,56 @@ export async function resolveAndPayout(params: { issueNumber: number; winningPrAuthor: string; winningPrBody: string | null; + winningPrCommitAuthors?: string[]; amount: number; issueId: string; -}): Promise { +}): Promise { const walletFromPr = extractWalletFromPrBody(params.winningPrBody); - const recipientEmail = await getRecipientEmail(params.winningPrAuthor); + const recipientWallets = extractRecipientWalletsFromPrBody(params.winningPrBody); + const manualSplit = parseManualSplit(params.winningPrBody); + const weightedRecipients = + manualSplit.length > 0 + ? manualSplit + : buildCommitWeightedSplit(params.winningPrAuthor, params.winningPrCommitAuthors); + const recipients = splitAmountByWeight(params.amount, weightedRecipients); - if (walletFromPr) { - return callLocusPayoutByWallet({ - toAddress: walletFromPr, - amount: params.amount, - memo: `Bountic payout for ${params.issueId}`, - }); + if (recipients.length === 0) { + throw new Error("No payout recipients could be resolved"); } - if (recipientEmail) { - return callLocusPayoutByEmail({ - toEmail: recipientEmail, - amount: params.amount, - memo: `Bountic payout for ${params.issueId}`, - }); + const payoutResults: SplitPayoutResult[] = []; + + for (const recipient of recipients) { + const recipientWallet = + recipientWallets.get(recipient.username.toLowerCase()) ?? + (recipient.username.toLowerCase() === params.winningPrAuthor.toLowerCase() + ? walletFromPr + : null); + + payoutResults.push( + await resolveSinglePayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + recipientUsername: recipient.username, + recipientWallet, + amount: recipient.amount, + issueId: params.issueId, + }), + ); } - return handleUnclaimedPayout({ - owner: params.owner, - repo: params.repo, - issueNumber: params.issueNumber, - winningPrAuthor: params.winningPrAuthor, - amount: params.amount, - issueId: params.issueId, - }); -} \ No newline at end of file + const primaryResult = payoutResults[0]; + + return { + transactionId: payoutResults.map((result) => result.transactionId).join(","), + txHash: payoutResults + .map((result) => result.txHash) + .filter((txHash): txHash is string => typeof txHash === "string" && txHash.length > 0) + .join(",") || null, + payoutType: payoutResults.length === 1 ? primaryResult.payoutType : "split", + recipientEmail: payoutResults.length === 1 ? primaryResult.recipientEmail ?? null : null, + recipientWallet: payoutResults.length === 1 ? primaryResult.recipientWallet ?? null : null, + recipients: payoutResults, + }; +} diff --git a/lib/clients/locus/mock.ts b/lib/clients/locus/mock.ts index 5444fb2..3856c02 100644 --- a/lib/clients/locus/mock.ts +++ b/lib/clients/locus/mock.ts @@ -52,7 +52,7 @@ export function getMockLocusClient(): LocusClient { return payload as T; } - if (normalized === "/pay/send" && options.method === "POST") { + if ((normalized === "/pay/send" || normalized === "/pay/send-email") && options.method === "POST") { const transactionId = randomId("tx"); const payload: PayoutPayload = { transaction_id: transactionId, diff --git a/public/LLM.md b/public/LLM.md index d24cb53..a0bf95c 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": "alice", + "amount": 0, + "payoutType": "wallet|email|unclaimed", + "recipientEmail": null, + "recipientWallet": null, + "txHash": null, + "transactionId": "..." + } + ], "approvedBy": "github_username" } } @@ -211,7 +222,23 @@ Body: ``` -The tag must be present in the PR body exactly once. It should be parseable via regex. +Use this tag for single-recipient wallet payouts. + +- Per-recipient wallet payout tags: +``` + + +``` + +- Optional explicit split weights: +``` + +``` + +If no explicit split is provided, Bountic splits the payout by GitHub commit-author attribution on the merged PR. If an explicit split is provided, weights may be percentages or simple relative weights and are rounded to cents. ## State Rules