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
18 changes: 10 additions & 8 deletions app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
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
94 changes: 56 additions & 38 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -67,14 +70,16 @@ 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();

const { error: updateError } = await supabase
.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,
})
Expand All @@ -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}`);
Expand All @@ -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,
})),
};
}
}
158 changes: 156 additions & 2 deletions lib/bounty/services/payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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_REGEX = /<!--\s*bountic-split:\s*([\s\S]*?)-->/i;
const WALLET_REGEX = /^0x[a-fA-F0-9]{40}$/;

export type PayoutResult = {
transactionId: string;
Expand All @@ -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<typeof line> => 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<string | null> {
const supabase = getSupabaseServiceClient();
const { data: user } = await supabase
Expand Down Expand Up @@ -173,4 +252,79 @@ export async function resolveAndPayout(params: {
amount: params.amount,
issueId: params.issueId,
});
}
}

async function payoutRecipient(params: {
owner: string;
repo: string;
issueNumber: number;
username: string;
wallet: string | null;
amount: number;
issueId: string;
}): Promise<RecipientPayoutResult> {
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<RecipientPayoutResult[]> {
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 }];
}
2 changes: 1 addition & 1 deletion lib/clients/locus/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down