diff --git a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx index b72106c..d476147 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, recipientEmail, recipientWallet, recipients } = response.payout; let message = ""; - if (payoutType === "wallet" && recipientWallet) { + if (recipients.length > 1) { + message = `Split payout sent to ${recipients.length} recipients.`; + } 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..8cd0945 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -166,6 +166,15 @@ export async function approveBounty(params: { txHash: string | null; transactionId: string; approvedBy: string; + recipients: Array<{ + username: string; + amount: number; + payoutType: "wallet" | "email" | "unclaimed"; + recipientEmail?: string | null; + recipientWallet?: string | null; + txHash: string | null; + transactionId: string; + }>; }; }> { const res = await fetch( diff --git a/lib/bounty/services/approve-payout.ts b/lib/bounty/services/approve-payout.ts index 934f48c..9bb7438 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -57,8 +57,7 @@ export async function approveBountyPayout(params: { } } - // only single payout for now, later multiple payouts - const payoutResult = await resolveAndPayout({ + const payout = await resolveAndPayout({ owner: params.owner, repo: params.repo, issueNumber: params.issueNumber, @@ -67,6 +66,7 @@ export async function approveBountyPayout(params: { amount: bounty.total_amount, issueId, }); + const primaryPayoutResult = payout.results[0]; const now = new Date().toISOString(); @@ -74,7 +74,7 @@ export async function approveBountyPayout(params: { .from("bounties") .update({ status: "PAID", - payout_tx_hash: payoutResult.txHash, + payout_tx_hash: primaryPayoutResult.txHash, paid_at: now, approved_by: params.approvedBy, }) @@ -84,38 +84,44 @@ 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({ + const payoutEvents = payout.results.map(result => ({ issue_id: issueId, - recipient_username: bounty.winning_pr_author, - amount: bounty.total_amount, - locus_transaction_id: payoutResult.transactionId, - transaction_hash: payoutResult.txHash, - status: "SUCCESS", + recipient_username: result.recipientUsername, + amount: result.amount, + locus_transaction_id: result.transactionId, + transaction_hash: result.txHash, + status: "SUCCESS" as const, metadata: { approved_by: params.approvedBy, payout_source: "web", - payout_type: payoutResult.payoutType, - recipient_email: payoutResult.recipientEmail, - recipient_wallet: payoutResult.recipientWallet, + payout_type: result.payoutType, + recipient_email: result.recipientEmail, + recipient_wallet: result.recipientWallet, + split_payout: payout.isSplit, }, - }); + })); + + const { error: payoutEventError } = await supabase.from("payout_events").insert(payoutEvents); 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( + payout.results.map(result => ({ + issue_id: issueId, + event_type: "PAYOUT_SENT" as const, + actor_username: result.recipientUsername, + amount: result.amount, + tx_hash: result.txHash, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: result.payoutType, + split_payout: payout.isSplit, + }, + })), + ); if (activityError) { throw new Error(`Failed to persist payout activity: ${activityError.message}`); @@ -126,12 +132,21 @@ 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: primaryPayoutResult.recipientUsername, + payoutType: primaryPayoutResult.payoutType, + recipientEmail: primaryPayoutResult.recipientEmail, + recipientWallet: primaryPayoutResult.recipientWallet, + txHash: primaryPayoutResult.txHash, + transactionId: primaryPayoutResult.transactionId, approvedBy: params.approvedBy, + recipients: payout.results.map(result => ({ + username: result.recipientUsername, + amount: result.amount, + payoutType: result.payoutType, + recipientEmail: result.recipientEmail, + recipientWallet: result.recipientWallet, + txHash: result.txHash, + transactionId: result.transactionId, + })), }; -} \ No newline at end of file +} diff --git a/lib/bounty/services/payout.ts b/lib/bounty/services/payout.ts index 3f40a1c..aeca58c 100644 --- a/lib/bounty/services/payout.ts +++ b/lib/bounty/services/payout.ts @@ -6,21 +6,123 @@ import { getSupabaseServerEnv } from "@/lib/env/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; const BOUNTIC_ADDRESS_REGEX = //i; +const BOUNTIC_SPLIT_REGEX = //i; +const SPLIT_LINE_REGEX = /^@?([a-zA-Z0-9-]+)\s+(\d+(?:\.\d+)?)(%)?(?:\s+(0x[a-fA-F0-9]{40}))?$/; export type PayoutResult = { transactionId: string; txHash: string | null; payoutType: "wallet" | "email" | "unclaimed"; + recipientUsername: string; + amount: number; recipientEmail?: string | null; recipientWallet?: string | null; }; +export type SplitPayoutResult = { + results: PayoutResult[]; + totalAmount: number; + isSplit: boolean; +}; + +type ParsedPayoutRecipient = { + username: string; + amount: number; + wallet: string | null; +}; + +type ParsedSplitLine = { + username: string; + value: number; + isPercent: boolean; + wallet: string | null; +}; + function extractWalletFromPrBody(prBody: string | null): string | null { if (!prBody) return null; const match = BOUNTIC_ADDRESS_REGEX.exec(prBody); return match ? match[1] : null; } +function roundCurrency(amount: number): number { + return Math.round(amount * 100) / 100; +} + +function parseSplitLines(prBody: string | null): ParsedSplitLine[] { + if (!prBody) return []; + + const blockMatch = BOUNTIC_SPLIT_REGEX.exec(prBody); + if (!blockMatch) return []; + + const splitLines = blockMatch[1] + .split("\n") + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")); + + return splitLines.map(line => { + const match = SPLIT_LINE_REGEX.exec(line); + if (!match) { + throw new Error(`Invalid bountic-split line: "${line}"`); + } + + return { + username: match[1], + value: Number(match[2]), + isPercent: Boolean(match[3]), + wallet: match[4] ?? null, + }; + }); +} + +function resolveSplitRecipients(prBody: string | null, totalAmount: number): ParsedPayoutRecipient[] { + const splitLines = parseSplitLines(prBody); + if (splitLines.length === 0) return []; + + const hasPercent = splitLines.some(line => line.isPercent); + const hasFixedAmount = splitLines.some(line => !line.isPercent); + + if (hasPercent && hasFixedAmount) { + throw new Error("bountic-split cannot mix percentages and fixed USDC amounts"); + } + + if (new Set(splitLines.map(line => line.username.toLowerCase())).size !== splitLines.length) { + throw new Error("bountic-split contains duplicate recipients"); + } + + if (hasPercent) { + const percentTotal = splitLines.reduce((sum, line) => sum + line.value, 0); + if (Math.abs(percentTotal - 100) > 0.001) { + throw new Error("bountic-split percentages must total 100%"); + } + + let distributedAmount = 0; + return splitLines.map((line, index) => { + const isLast = index === splitLines.length - 1; + const amount = isLast + ? roundCurrency(totalAmount - distributedAmount) + : roundCurrency((totalAmount * line.value) / 100); + distributedAmount += amount; + + return { + username: line.username, + amount, + wallet: line.wallet, + }; + }); + } + + const amountTotal = roundCurrency(splitLines.reduce((sum, line) => sum + line.value, 0)); + if (Math.abs(amountTotal - roundCurrency(totalAmount)) > 0.001) { + throw new Error("bountic-split fixed amounts must total the bounty amount"); + } + + return splitLines.map(line => ({ + username: line.username, + amount: roundCurrency(line.value), + wallet: line.wallet, + })); +} + async function getRecipientEmail(githubUsername: string): Promise { const supabase = getSupabaseServiceClient(); const { data: user } = await supabase @@ -49,6 +151,7 @@ async function commentOnIssue(params: { } export async function callLocusPayoutByEmail(params: { + recipientUsername: string; toEmail: string; amount: number; memo: string; @@ -73,6 +176,8 @@ export async function callLocusPayoutByEmail(params: { transactionId: payload.transaction_id, txHash: payload.tx_hash ?? null, payoutType: "email", + recipientUsername: params.recipientUsername, + amount: params.amount, recipientEmail: params.toEmail, }; } catch (error) { @@ -82,6 +187,7 @@ export async function callLocusPayoutByEmail(params: { } export async function callLocusPayoutByWallet(params: { + recipientUsername: string; toAddress: string; amount: number; memo: string; @@ -104,6 +210,8 @@ export async function callLocusPayoutByWallet(params: { transactionId: payload.transaction_id, txHash: payload.tx_hash ?? null, payoutType: "wallet", + recipientUsername: params.recipientUsername, + amount: params.amount, recipientWallet: params.toAddress, }; } @@ -133,25 +241,27 @@ Once connected, a maintainer can approve your payout and the funds will be sent transactionId: `unclaimed_${Date.now()}`, txHash: null, payoutType: "unclaimed", + recipientUsername: params.winningPrAuthor, + amount: params.amount, recipientEmail: null, }; } -export async function resolveAndPayout(params: { +async function payoutRecipient(params: { owner: string; repo: string; issueNumber: number; - winningPrAuthor: string; - winningPrBody: string | null; + username: string; + wallet: string | null; amount: number; issueId: string; }): Promise { - const walletFromPr = extractWalletFromPrBody(params.winningPrBody); - const recipientEmail = await getRecipientEmail(params.winningPrAuthor); + const recipientEmail = await getRecipientEmail(params.username); - if (walletFromPr) { + if (params.wallet) { return callLocusPayoutByWallet({ - toAddress: walletFromPr, + recipientUsername: params.username, + toAddress: params.wallet, amount: params.amount, memo: `Bountic payout for ${params.issueId}`, }); @@ -159,6 +269,7 @@ export async function resolveAndPayout(params: { if (recipientEmail) { return callLocusPayoutByEmail({ + recipientUsername: params.username, toEmail: recipientEmail, amount: params.amount, memo: `Bountic payout for ${params.issueId}`, @@ -169,8 +280,59 @@ export async function resolveAndPayout(params: { owner: params.owner, repo: params.repo, issueNumber: params.issueNumber, - winningPrAuthor: params.winningPrAuthor, + winningPrAuthor: params.username, + amount: params.amount, + issueId: params.issueId, + }); +} + +export async function resolveAndPayout(params: { + owner: string; + repo: string; + issueNumber: number; + winningPrAuthor: string; + winningPrBody: string | null; + amount: number; + issueId: string; +}): Promise { + const splitRecipients = resolveSplitRecipients(params.winningPrBody, params.amount); + + if (splitRecipients.length > 0) { + const results: PayoutResult[] = []; + + for (const recipient of splitRecipients) { + results.push(await payoutRecipient({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + username: recipient.username, + wallet: recipient.wallet, + amount: recipient.amount, + issueId: params.issueId, + })); + } + + return { + results, + totalAmount: params.amount, + isSplit: true, + }; + } + + const walletFromPr = extractWalletFromPrBody(params.winningPrBody); + const result = await payoutRecipient({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + username: params.winningPrAuthor, + wallet: walletFromPr, amount: params.amount, issueId: params.issueId, }); -} \ No newline at end of file + + return { + results: [result], + totalAmount: params.amount, + isSplit: false, + }; +}