diff --git a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx index b72106c..e4d4e68 100644 --- a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx +++ b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx @@ -26,17 +26,19 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) { startTransition(async () => { try { const response = await approveBounty({ owner, repo, issueNumber }); - const { payoutType, recipientEmail, recipientWallet } = response.payout; - + const payouts = response.payout.payouts ?? [response.payout]; + let message = ""; - 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}`; - } else if (payoutType === "unclaimed") { + if (payouts.length > 1) { + message = `${payouts.length} payouts sent for $${response.payout.amount.toFixed(2)} USDC total.`; + } else if (response.payout.payoutType === "wallet" && response.payout.recipientWallet) { + message = `Payout sent to wallet ${response.payout.recipientWallet.slice(0, 6)}...${response.payout.recipientWallet.slice(-4)}`; + } else if (response.payout.payoutType === "email" && response.payout.recipientEmail) { + message = `Payout sent to ${response.payout.recipientEmail}`; + } else if (response.payout.payoutType === "unclaimed") { message = "Winner not connected. Notified via issue comment to claim."; } - + setSuccessTxHash(message); router.refresh(); } catch (e) { diff --git a/lib/api/client.ts b/lib/api/client.ts index d78c429..fe3397e 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; + payouts: Array<{ + recipient: 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..29db13d 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -1,10 +1,14 @@ import "server-only"; -import { resolveAndPayout } from "@/lib/bounty/services/payout"; +import { resolveAndPayoutAll } 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"; +import type { Database } from "@/lib/types/database"; + +type PayoutEventInsert = Database["public"]["Tables"]["payout_events"]["Insert"]; +type ActivityEventInsert = Database["public"]["Tables"]["activity_events"]["Insert"]; export async function approveBountyPayout(params: { owner: string; @@ -57,8 +61,7 @@ export async function approveBountyPayout(params: { } } - // only single payout for now, later multiple payouts - const payoutResult = await resolveAndPayout({ + const payoutResults = await resolveAndPayoutAll({ owner: params.owner, repo: params.repo, issueNumber: params.issueNumber, @@ -67,6 +70,8 @@ export async function approveBountyPayout(params: { amount: bounty.total_amount, issueId, }); + const primaryPayout = payoutResults[0]; + const payoutTxHashes = payoutResults.map(payout => payout.txHash).filter(Boolean); const now = new Date().toISOString(); @@ -74,7 +79,7 @@ export async function approveBountyPayout(params: { .from("bounties") .update({ status: "PAID", - payout_tx_hash: payoutResult.txHash, + payout_tx_hash: payoutTxHashes.length > 0 ? payoutTxHashes.join(",") : primaryPayout.txHash, paid_at: now, approved_by: params.approvedBy, }) @@ -84,38 +89,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 payoutEvents: PayoutEventInsert[] = payoutResults.map(payoutResult => ({ + issue_id: issueId, + recipient_username: payoutResult.recipientUsername, + amount: payoutResult.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(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 activityEvents: ActivityEventInsert[] = payoutResults.map(payoutResult => ({ + issue_id: issueId, + event_type: "PAYOUT_SENT", + actor_username: payoutResult.recipientUsername, + amount: payoutResult.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(activityEvents); if (activityError) { throw new Error(`Failed to persist payout activity: ${activityError.message}`); @@ -126,12 +135,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: primaryPayout.recipientUsername, + payoutType: primaryPayout.payoutType, + recipientEmail: primaryPayout.recipientEmail, + recipientWallet: primaryPayout.recipientWallet, + txHash: primaryPayout.txHash, + transactionId: primaryPayout.transactionId, approvedBy: params.approvedBy, + payouts: payoutResults.map(payoutResult => ({ + recipient: payoutResult.recipientUsername, + amount: payoutResult.amount, + payoutType: payoutResult.payoutType, + recipientEmail: payoutResult.recipientEmail, + recipientWallet: payoutResult.recipientWallet, + txHash: payoutResult.txHash, + transactionId: payoutResult.transactionId, + })), }; -} \ No newline at end of file +} diff --git a/lib/bounty/services/payout.ts b/lib/bounty/services/payout.ts index 3f40a1c..7e36070 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_SPLIT_REGEX = //i; +const WALLET_REGEX = /^0x[a-fA-F0-9]{40}$/; export type PayoutResult = { transactionId: string; @@ -15,12 +17,89 @@ export type PayoutResult = { recipientWallet?: string | null; }; -function extractWalletFromPrBody(prBody: string | null): string | null { +export type RecipientPayoutResult = PayoutResult & { + recipientUsername: string; + amount: number; +}; + +type PayoutSplit = { + username: string; + amount: number; + wallet: string | null; +}; + +export function extractWalletFromPrBody(prBody: string | null): string | null { if (!prBody) return null; const match = BOUNTIC_ADDRESS_REGEX.exec(prBody); return match ? match[1] : null; } +function parseSplitLine(line: string): { username: string; value: string; wallet: string | null } | null { + const normalized = line.trim(); + if (!normalized || normalized.startsWith("#")) return null; + + const parts = normalized.split(/\s+/); + if (parts.length < 2) { + throw new Error(`Invalid bountic-split line: "${line}"`); + } + + const username = parts[0].replace(/^@/, ""); + const value = parts[1]; + const wallet = parts.find(part => WALLET_REGEX.test(part)) ?? null; + + if (!/^[a-zA-Z0-9-]+$/.test(username)) { + throw new Error(`Invalid bountic-split username: "${parts[0]}"`); + } + + return { username, value, wallet }; +} + +export function parsePayoutSplits(prBody: string | null, totalAmount: number): PayoutSplit[] | null { + if (!prBody) return null; + + const match = BOUNTIC_SPLIT_REGEX.exec(prBody); + if (!match) return null; + + const parsedLines = match[1] + .split(/\r?\n/) + .map(parseSplitLine) + .filter((line): line is NonNullable => Boolean(line)); + + if (parsedLines.length === 0) { + throw new Error("bountic-split block must include at least one recipient"); + } + + const usesPercent = parsedLines.some(line => line.value.endsWith("%")); + const usesFixed = parsedLines.some(line => !line.value.endsWith("%")); + + if (usesPercent && usesFixed) { + throw new Error("bountic-split cannot mix percentages and fixed USDC amounts"); + } + + const splits = parsedLines.map(line => { + const rawValue = line.value.endsWith("%") ? line.value.slice(0, -1) : line.value; + const numericValue = Number(rawValue); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + throw new Error(`Invalid bountic-split amount for @${line.username}`); + } + + const amount = usesPercent ? (totalAmount * numericValue) / 100 : numericValue; + return { + username: line.username, + amount: Math.round(amount * 100) / 100, + wallet: line.wallet, + }; + }); + + const splitTotal = Math.round(splits.reduce((sum, split) => sum + split.amount, 0) * 100) / 100; + const expectedTotal = Math.round(totalAmount * 100) / 100; + if (splitTotal !== expectedTotal) { + throw new Error(`bountic-split total (${splitTotal}) must equal bounty amount (${expectedTotal})`); + } + + return splits; +} + async function getRecipientEmail(githubUsername: string): Promise { const supabase = getSupabaseServiceClient(); const { data: user } = await supabase @@ -173,4 +252,79 @@ export async function resolveAndPayout(params: { amount: params.amount, issueId: params.issueId, }); -} \ No newline at end of file +} + +async function payoutRecipient(params: { + owner: string; + repo: string; + issueNumber: number; + username: string; + wallet: string | null; + amount: number; + issueId: string; +}): Promise { + if (params.wallet) { + const result = await callLocusPayoutByWallet({ + toAddress: params.wallet, + amount: params.amount, + memo: `Bountic payout for ${params.issueId} (@${params.username})`, + }); + + return { ...result, recipientUsername: params.username, amount: params.amount }; + } + + const recipientEmail = await getRecipientEmail(params.username); + if (recipientEmail) { + const result = await callLocusPayoutByEmail({ + toEmail: recipientEmail, + amount: params.amount, + memo: `Bountic payout for ${params.issueId} (@${params.username})`, + }); + + return { ...result, recipientUsername: params.username, amount: params.amount }; + } + + const result = await handleUnclaimedPayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + winningPrAuthor: params.username, + amount: params.amount, + issueId: params.issueId, + }); + + return { ...result, recipientUsername: params.username, amount: params.amount }; +} + +export async function resolveAndPayoutAll(params: { + owner: string; + repo: string; + issueNumber: number; + winningPrAuthor: string; + winningPrBody: string | null; + amount: number; + issueId: string; +}): Promise { + const splits = parsePayoutSplits(params.winningPrBody, params.amount); + + if (splits) { + const payouts: RecipientPayoutResult[] = []; + for (const split of splits) { + payouts.push( + await payoutRecipient({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + username: split.username, + wallet: split.wallet, + amount: split.amount, + issueId: params.issueId, + }), + ); + } + return payouts; + } + + const result = await resolveAndPayout(params); + return [{ ...result, recipientUsername: params.winningPrAuthor, amount: params.amount }]; +} 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,