diff --git a/lib/bounty/services/approve-payout.ts b/lib/bounty/services/approve-payout.ts index 934f48c..21a5531 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -1,3 +1,4 @@ + import "server-only"; import { resolveAndPayout } from "@/lib/bounty/services/payout"; @@ -57,7 +58,6 @@ export async function approveBountyPayout(params: { } } - // only single payout for now, later multiple payouts const payoutResult = await resolveAndPayout({ owner: params.owner, repo: params.repo, @@ -84,21 +84,23 @@ 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}`); @@ -114,6 +116,13 @@ export async function approveBountyPayout(params: { approved_by: params.approvedBy, payout_source: "web", payout_type: payoutResult.payoutType, + recipients: payoutResult.recipients.map((recipient) => ({ + username: recipient.recipientUsername, + amount: recipient.amount, + payout_type: recipient.payoutType, + recipient_wallet: recipient.recipientWallet, + recipient_email: recipient.recipientEmail, + })), }, }); @@ -130,8 +139,9 @@ export async function approveBountyPayout(params: { payoutType: payoutResult.payoutType, recipientEmail: payoutResult.recipientEmail, recipientWallet: payoutResult.recipientWallet, + recipients: payoutResult.recipients, txHash: payoutResult.txHash, transactionId: payoutResult.transactionId, 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..e6fbbed 100644 --- a/lib/bounty/services/payout.ts +++ b/lib/bounty/services/payout.ts @@ -5,7 +5,8 @@ import { getSupabaseServiceClient } from "@/lib/clients/supabase/server"; import { getSupabaseServerEnv } from "@/lib/env/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; -const BOUNTIC_ADDRESS_REGEX = //i; +const BOUNTIC_ADDRESS_COMMENT_REGEX = //gi; +const EVM_ADDRESS_REGEX = /0x[a-fA-F0-9]{40}/; export type PayoutResult = { transactionId: string; @@ -13,12 +14,89 @@ export type PayoutResult = { payoutType: "wallet" | "email" | "unclaimed"; recipientEmail?: string | null; recipientWallet?: string | null; + recipients: PayoutRecipientResult[]; }; -function extractWalletFromPrBody(prBody: string | null): string | null { - if (!prBody) return null; - const match = BOUNTIC_ADDRESS_REGEX.exec(prBody); - return match ? match[1] : null; +export type PayoutRecipientResult = { + transactionId: string; + txHash: string | null; + payoutType: "wallet" | "email" | "unclaimed"; + recipientUsername: string; + amount: number; + recipientEmail?: string | null; + recipientWallet?: string | null; +}; + +type WalletRecipient = { + username: string; + wallet: string; +}; + +function parseUsernameFromAddressTag(rawTag: string, wallet: string, fallbackUsername: string): string { + const withoutWallet = rawTag.replace(wallet, "").trim(); + const usernameToken = withoutWallet + .replace(/^[=:\s-]+/, "") + .replace(/[=:\s-]+$/, "") + .match(/^@?([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?)$/)?.[1]; + + return usernameToken || fallbackUsername; +} + +function extractWalletRecipientsFromPrBody( + prBody: string | null, + fallbackUsername: string, +): WalletRecipient[] { + if (!prBody) return []; + + const recipients: WalletRecipient[] = []; + const seenWallets = new Set(); + + for (const match of prBody.matchAll(BOUNTIC_ADDRESS_COMMENT_REGEX)) { + const rawTag = match[1]?.trim() ?? ""; + const walletMatch = EVM_ADDRESS_REGEX.exec(rawTag); + if (!walletMatch) continue; + + const wallet = walletMatch[0]; + const normalizedWallet = wallet.toLowerCase(); + if (seenWallets.has(normalizedWallet)) continue; + + recipients.push({ + username: parseUsernameFromAddressTag(rawTag, wallet, fallbackUsername), + wallet, + }); + seenWallets.add(normalizedWallet); + } + + return recipients; +} + +function splitAmountEvenly(amount: number, recipientCount: number): number[] { + if (recipientCount < 1) return []; + + const totalCents = Math.round(amount * 100); + const baseCents = Math.floor(totalCents / recipientCount); + const remainder = totalCents % recipientCount; + + return Array.from({ length: recipientCount }, (_, index) => ( + (baseCents + (index < remainder ? 1 : 0)) / 100 + )); +} + +function summarizePayouts(recipients: PayoutRecipientResult[]): PayoutResult { + const transactionIds = recipients.map((recipient) => recipient.transactionId).join(","); + const txHashes = recipients + .map((recipient) => recipient.txHash) + .filter((txHash): txHash is string => Boolean(txHash)); + const firstRecipient = recipients[0]; + + return { + transactionId: transactionIds, + txHash: txHashes.length > 0 ? txHashes.join(",") : null, + payoutType: firstRecipient?.payoutType ?? "unclaimed", + recipientEmail: firstRecipient?.recipientEmail ?? null, + recipientWallet: firstRecipient?.recipientWallet ?? null, + recipients, + }; } async function getRecipientEmail(githubUsername: string): Promise { @@ -52,7 +130,8 @@ export async function callLocusPayoutByEmail(params: { toEmail: string; amount: number; memo: string; -}): Promise { + recipientUsername: string; +}): Promise { const locus = getLocusServerClient(); try { @@ -73,6 +152,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) { @@ -85,7 +166,8 @@ export async function callLocusPayoutByWallet(params: { toAddress: string; amount: number; memo: string; -}): Promise { + recipientUsername: string; +}): Promise { const locus = getLocusServerClient(); const payload = await locus.request<{ @@ -104,6 +186,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, }; } @@ -115,7 +199,7 @@ export async function handleUnclaimedPayout(params: { winningPrAuthor: string; amount: number; issueId: string; -}): Promise { +}): Promise { const env = getSupabaseServerEnv(); await commentOnIssue({ @@ -133,6 +217,8 @@ 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, }; } @@ -146,31 +232,44 @@ export async function resolveAndPayout(params: { amount: number; issueId: string; }): Promise { - const walletFromPr = extractWalletFromPrBody(params.winningPrBody); + const walletRecipients = extractWalletRecipientsFromPrBody( + params.winningPrBody, + params.winningPrAuthor, + ); const recipientEmail = await getRecipientEmail(params.winningPrAuthor); - if (walletFromPr) { - return callLocusPayoutByWallet({ - toAddress: walletFromPr, - amount: params.amount, - memo: `Bountic payout for ${params.issueId}`, - }); + if (walletRecipients.length > 0) { + const splitAmounts = splitAmountEvenly(params.amount, walletRecipients.length); + const payoutRecipients: PayoutRecipientResult[] = []; + + for (const [index, recipient] of walletRecipients.entries()) { + payoutRecipients.push(await callLocusPayoutByWallet({ + toAddress: recipient.wallet, + amount: splitAmounts[index], + memo: `Bountic payout for ${params.issueId}`, + recipientUsername: recipient.username, + })); + } + + return summarizePayouts(payoutRecipients); } if (recipientEmail) { - return callLocusPayoutByEmail({ + return summarizePayouts([await callLocusPayoutByEmail({ toEmail: recipientEmail, amount: params.amount, memo: `Bountic payout for ${params.issueId}`, - }); + recipientUsername: params.winningPrAuthor, + })]); } - return handleUnclaimedPayout({ + return summarizePayouts([await 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 + })]); +} +