Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
9 changes: 9 additions & 0 deletions lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
102 changes: 67 additions & 35 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
})
Expand All @@ -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}`);
Expand All @@ -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,
})),
};
}
}
87 changes: 84 additions & 3 deletions lib/bounty/services/payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { getSupabaseServerEnv } from "@/lib/env/server";
import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server";

const BOUNTIC_ADDRESS_REGEX = /<!--\s*bountic-address:\s*(0x[a-fA-F0-9]{40})\s*-->/i;
const BOUNTIC_SPLIT_BLOCK_REGEX = /<!--\s*bountic-split:\s*([\s\S]*?)-->/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;
Expand All @@ -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<string | null> {
const supabase = getSupabaseServiceClient();
const { data: user } = await supabase
Expand Down Expand Up @@ -145,8 +223,11 @@ export async function resolveAndPayout(params: {
winningPrBody: string | null;
amount: number;
issueId: string;
recipientWallet?: string | null;
usePrBodyWallet?: boolean;
}): Promise<PayoutResult> {
const walletFromPr = extractWalletFromPrBody(params.winningPrBody);
const walletFromPr =
params.recipientWallet ?? (params.usePrBodyWallet === false ? null : extractWalletFromPrBody(params.winningPrBody));
const recipientEmail = await getRecipientEmail(params.winningPrAuthor);

if (walletFromPr) {
Expand All @@ -173,4 +254,4 @@ export async function resolveAndPayout(params: {
amount: params.amount,
issueId: params.issueId,
});
}
}