From e366651646cc2f41b99db6f08a339e36d43fc592 Mon Sep 17 00:00:00 2001 From: malifraco Date: Mon, 1 Jun 2026 04:17:11 -0300 Subject: [PATCH] Add split payout support --- .../issues/[issueNumber]/approve-button.tsx | 4 +- lib/api/client.ts | 9 ++ lib/bounty/services/approve-payout.ts | 102 ++++++++++++------ lib/bounty/services/payout.ts | 87 ++++++++++++++- 4 files changed, 163 insertions(+), 39 deletions(-) diff --git a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx index b72106c..295628b 100644 --- a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx +++ b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx @@ -29,7 +29,9 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) { const { payoutType, recipientEmail, recipientWallet } = response.payout; let message = ""; - if (payoutType === "wallet" && recipientWallet) { + if (response.payout.payouts && response.payout.payouts.length > 1) { + message = `Payout split sent to ${response.payout.payouts.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..0b7e065 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<{ + amount: number; + recipient: string; + 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..a85e07f 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -1,6 +1,6 @@ import "server-only"; -import { resolveAndPayout } from "@/lib/bounty/services/payout"; +import { extractPayoutRecipientsFromPrBody, resolveAndPayout } 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"; @@ -57,24 +57,41 @@ export async function approveBountyPayout(params: { } } - // 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, - amount: bounty.total_amount, - issueId, + const payoutRecipients = extractPayoutRecipientsFromPrBody({ + prBody: winningPrBody, + fallbackUsername: bounty.winning_pr_author, + totalAmount: bounty.total_amount, }); + const payoutResults = []; + for (const recipient of payoutRecipients) { + payoutResults.push({ + recipient, + result: await resolveAndPayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + winningPrAuthor: recipient.username, + winningPrBody, + amount: recipient.amount, + issueId, + recipientWallet: recipient.wallet, + usePrBodyWallet: payoutRecipients.length === 1, + }), + }); + } + const now = new Date().toISOString(); + const payoutTxHashes = payoutResults + .map(({ result }) => result.txHash) + .filter((txHash): txHash is string => !!txHash); + const primaryPayoutResult = payoutResults[0].result; const { error: updateError } = await supabase .from("bounties") .update({ status: "PAID", - payout_tx_hash: payoutResult.txHash, + payout_tx_hash: payoutTxHashes.length ? payoutTxHashes.join(",") : primaryPayoutResult.txHash, paid_at: now, approved_by: params.approvedBy, }) @@ -84,38 +101,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 payoutEventRows = payoutResults.map(({ recipient, 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: recipient.username, + amount: recipient.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 ?? null, + recipient_wallet: result.recipientWallet ?? null, + split_count: payoutResults.length, }, - }); + })); + + const { error: payoutEventError } = await supabase.from("payout_events").insert(payoutEventRows); if (payoutEventError) { throw new Error(`Failed to persist payout event: ${payoutEventError.message}`); } - const { error: activityError } = await supabase.from("activity_events").insert({ + const activityRows = payoutResults.map(({ recipient, result }) => ({ issue_id: issueId, - event_type: "PAYOUT_SENT", - actor_username: bounty.winning_pr_author, - amount: bounty.total_amount, - tx_hash: payoutResult.txHash, + event_type: "PAYOUT_SENT" as const, + actor_username: recipient.username, + amount: recipient.amount, + tx_hash: result.txHash, metadata: { approved_by: params.approvedBy, payout_source: "web", - payout_type: payoutResult.payoutType, + payout_type: result.payoutType, + split_count: payoutResults.length, }, - }); + })); + + const { error: activityError } = await supabase.from("activity_events").insert(activityRows); if (activityError) { throw new Error(`Failed to persist payout activity: ${activityError.message}`); @@ -126,12 +149,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: payoutResults.length === 1 ? payoutRecipients[0].username : "multiple", + payoutType: primaryPayoutResult.payoutType, + recipientEmail: primaryPayoutResult.recipientEmail, + recipientWallet: primaryPayoutResult.recipientWallet, + txHash: primaryPayoutResult.txHash, + transactionId: primaryPayoutResult.transactionId, approvedBy: params.approvedBy, + payouts: payoutResults.map(({ recipient, result }) => ({ + amount: recipient.amount, + recipient: recipient.username, + payoutType: result.payoutType, + recipientEmail: result.recipientEmail ?? null, + recipientWallet: result.recipientWallet ?? null, + 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..12e2345 100644 --- a/lib/bounty/services/payout.ts +++ b/lib/bounty/services/payout.ts @@ -6,6 +6,9 @@ import { getSupabaseServerEnv } from "@/lib/env/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; const BOUNTIC_ADDRESS_REGEX = //i; +const BOUNTIC_SPLIT_BLOCK_REGEX = //i; +const SPLIT_ENTRY_REGEX = /^@?([a-zA-Z0-9-]+)\s*(?::|=|\s)\s*(\d+(?:\.\d+)?)(%)?(?:\s+(0x[a-fA-F0-9]{40}))?$/; +const AMOUNT_EPSILON = 0.01; export type PayoutResult = { transactionId: string; @@ -15,12 +18,87 @@ export type PayoutResult = { recipientWallet?: string | null; }; -function extractWalletFromPrBody(prBody: string | null): string | null { +export type PayoutRecipient = { + 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; } +export function extractPayoutRecipientsFromPrBody(params: { + prBody: string | null; + fallbackUsername: string; + totalAmount: number; +}): PayoutRecipient[] { + const fallbackRecipient = [{ username: params.fallbackUsername, amount: params.totalAmount }]; + if (!params.prBody) return fallbackRecipient; + + const blockMatch = BOUNTIC_SPLIT_BLOCK_REGEX.exec(params.prBody); + if (!blockMatch) return fallbackRecipient; + + const rawEntries = blockMatch[1] + .split(/[\n,;]+/) + .map((entry) => entry.trim()) + .filter(Boolean); + + if (rawEntries.length === 0) { + throw new Error("bountic-split block must include at least one recipient"); + } + + const parsedEntries = rawEntries.map((entry) => { + const match = SPLIT_ENTRY_REGEX.exec(entry); + if (!match) { + throw new Error( + `Invalid bountic-split entry "${entry}". Use "@username 50%" or "@username 5.00 0xwallet".`, + ); + } + + return { + username: match[1], + value: Number(match[2]), + isPercent: !!match[3], + wallet: match[4] ?? null, + }; + }); + + const hasPercentEntries = parsedEntries.some((entry) => entry.isPercent); + const hasAmountEntries = parsedEntries.some((entry) => !entry.isPercent); + if (hasPercentEntries && hasAmountEntries) { + throw new Error("bountic-split cannot mix percentages and fixed amounts"); + } + + if (hasPercentEntries) { + const totalPercent = parsedEntries.reduce((sum, entry) => sum + entry.value, 0); + if (Math.abs(totalPercent - 100) > AMOUNT_EPSILON) { + throw new Error(`bountic-split percentages must total 100, got ${totalPercent}`); + } + + return parsedEntries.map((entry) => ({ + username: entry.username, + amount: Number(((params.totalAmount * entry.value) / 100).toFixed(2)), + wallet: entry.wallet, + })); + } + + const totalSplitAmount = parsedEntries.reduce((sum, entry) => sum + entry.value, 0); + if (Math.abs(totalSplitAmount - params.totalAmount) > AMOUNT_EPSILON) { + throw new Error( + `bountic-split fixed amounts must total ${params.totalAmount.toFixed(2)}, got ${totalSplitAmount.toFixed(2)}`, + ); + } + + return parsedEntries.map((entry) => ({ + username: entry.username, + amount: Number(entry.value.toFixed(2)), + wallet: entry.wallet, + })); +} + async function getRecipientEmail(githubUsername: string): Promise { const supabase = getSupabaseServiceClient(); const { data: user } = await supabase @@ -145,8 +223,11 @@ export async function resolveAndPayout(params: { winningPrBody: string | null; amount: number; issueId: string; + recipientWallet?: string | null; + usePrBodyWallet?: boolean; }): Promise { - const walletFromPr = extractWalletFromPrBody(params.winningPrBody); + const walletFromPr = + params.recipientWallet ?? (params.usePrBodyWallet === false ? null : extractWalletFromPrBody(params.winningPrBody)); const recipientEmail = await getRecipientEmail(params.winningPrAuthor); if (walletFromPr) { @@ -173,4 +254,4 @@ export async function resolveAndPayout(params: { amount: params.amount, issueId: params.issueId, }); -} \ No newline at end of file +}