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
9 changes: 6 additions & 3 deletions app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ 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];
const { payoutType, recipientEmail, recipientWallet } = payouts[0];

let message = "";
if (payoutType === "wallet" && recipientWallet) {
if (payouts.length > 1) {
message = `Approved ${payouts.length} split payouts totaling $${response.payout.amount.toFixed(2)} USDC`;
} 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<{
recipient: string;
amount: number;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail?: string | null;
recipientWallet?: string | null;
txHash: string | null;
transactionId: string;
}>;
};
}> {
const res = await fetch(
Expand Down
89 changes: 89 additions & 0 deletions lib/bounty/payout-recipients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export type PullRequestCommitContributor = {
author?: { login?: string | null } | null;
committer?: { login?: string | null } | null;
commit?: { message?: string | null } | null;
};

export type PayoutShare = {
username: string;
amount: number;
};

function addUniqueLogin(
logins: string[],
seen: Set<string>,
login: string | null | undefined,
) {
const normalized = login?.trim();
if (!normalized) return;

const key = normalized.toLowerCase();
if (seen.has(key)) return;

seen.add(key);
logins.push(normalized);
}

function getLoginFromNoreplyEmail(email: string): string | null {
const localPart = email.trim().toLowerCase().split("@")[0];
if (!localPart) return null;

const login = localPart.includes("+")
? localPart.slice(localPart.indexOf("+") + 1)
: localPart;

return /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/.test(login) ? login : null;
}

export function getCoauthorLogins(message: string | null | undefined): string[] {
if (!message) return [];

const coauthorRegex = /^Co-authored-by:\s*.+?<([^>]+)>/gim;
const logins: string[] = [];
const seen = new Set<string>();

for (const match of message.matchAll(coauthorRegex)) {
const email = match[1];
const login = email ? getLoginFromNoreplyEmail(email) : null;
addUniqueLogin(logins, seen, login);
}

return logins;
}

export function getUniqueContributorLogins(
primaryAuthor: string,
commits: PullRequestCommitContributor[],
): string[] {
const logins: string[] = [];
const seen = new Set<string>();

addUniqueLogin(logins, seen, primaryAuthor);

for (const commit of commits) {
addUniqueLogin(logins, seen, commit.author?.login);
addUniqueLogin(logins, seen, commit.committer?.login);
for (const coauthorLogin of getCoauthorLogins(commit.commit?.message)) {
addUniqueLogin(logins, seen, coauthorLogin);
}
}

return logins;
}

export function splitBountyAmount(totalAmount: number, usernames: string[]): PayoutShare[] {
const recipients = usernames.filter((username) => username.trim().length > 0);

if (recipients.length === 0) {
return [];
}

const totalCents = Math.round(totalAmount * 100);
const baseCents = Math.floor(totalCents / recipients.length);
const remainderCents = totalCents % recipients.length;

return recipients.map((username, index) => ({
username,
amount: (baseCents + (index < remainderCents ? 1 : 0)) / 100,
}));
}
129 changes: 83 additions & 46 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { getUniqueContributorLogins, splitBountyAmount } from "@/lib/bounty/payout-recipients";

export async function approveBountyPayout(params: {
owner: string;
Expand Down Expand Up @@ -42,6 +43,7 @@ export async function approveBountyPayout(params: {
}

let winningPrBody: string | null = null;
let contributorLogins = [bounty.winning_pr_author];
if (bounty.winning_pr_number) {
try {
const installationId = await getGithubRepoInstallationId(params.owner, params.repo);
Expand All @@ -52,29 +54,57 @@ export async function approveBountyPayout(params: {
pull_number: bounty.winning_pr_number,
});
winningPrBody = prResponse.data.body ?? null;

const commitsResponse = await github.rest.pulls.listCommits({
owner: params.owner,
repo: params.repo,
pull_number: bounty.winning_pr_number,
per_page: 100,
});

contributorLogins = getUniqueContributorLogins(
bounty.winning_pr_author,
commitsResponse.data,
);
} catch (err) {
console.warn("Failed to fetch PR body for wallet extraction:", err);
console.warn("Failed to fetch PR metadata for payout split:", err);
}
}

// 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 payoutShares = splitBountyAmount(bounty.total_amount, contributorLogins);
const payoutResults = [];

for (const share of payoutShares) {
const payoutResult = await resolveAndPayout({
owner: params.owner,
repo: params.repo,
issueNumber: params.issueNumber,
winningPrAuthor: share.username,
winningPrBody: share.username === bounty.winning_pr_author ? winningPrBody : null,
amount: share.amount,
issueId,
});

payoutResults.push({
recipient: share.username,
amount: share.amount,
...payoutResult,
});
}

const primaryPayout = payoutResults[0];
const payoutTxHash = payoutResults
.map((result) => result.txHash)
.filter((txHash): txHash is string => Boolean(txHash))
.join(",");

const now = new Date().toISOString();

const { error: updateError } = await supabase
.from("bounties")
.update({
status: "PAID",
payout_tx_hash: payoutResult.txHash,
payout_tx_hash: payoutTxHash || null,
paid_at: now,
approved_by: params.approvedBy,
})
Expand All @@ -84,38 +114,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({
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(
payoutResults.map((result) => ({
issue_id: issueId,
recipient_username: result.recipient,
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: result.payoutType,
recipient_email: result.recipientEmail,
recipient_wallet: result.recipientWallet,
split_recipients: payoutResults.length,
},
})),
);

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(
payoutResults.map((result) => ({
issue_id: issueId,
event_type: "PAYOUT_SENT" as const,
actor_username: result.recipient,
amount: result.amount,
tx_hash: result.txHash,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: result.payoutType,
split_recipients: payoutResults.length,
},
})),
);

if (activityError) {
throw new Error(`Failed to persist payout activity: ${activityError.message}`);
Expand All @@ -126,12 +162,13 @@ 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.recipient,
payoutType: primaryPayout.payoutType,
recipientEmail: primaryPayout.recipientEmail,
recipientWallet: primaryPayout.recipientWallet,
txHash: primaryPayout.txHash,
transactionId: primaryPayout.transactionId,
approvedBy: params.approvedBy,
payouts: payoutResults,
};
}
}